release: merge develop into rc/v12.2.0

This commit is contained in:
Maribeth Moffatt
2025-06-26 13:59:01 -07:00
43 changed files with 1093 additions and 589 deletions

View File

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

View File

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

View File

@@ -1721,6 +1721,11 @@ export class BlockSvg
this.dragStrategy = dragStrategy;
}
/** Returns whether this block is copyable or not. */
isCopyable(): boolean {
return this.isOwnDeletable() && this.isOwnMovable();
}
/** Returns whether this block is movable or not. */
override isMovable(): boolean {
return this.dragStrategy.isMovable();

View File

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

View File

@@ -173,6 +173,11 @@ export class TextInputBubble extends Bubble {
browserEvents.conditionalBind(textArea, 'wheel', this, (e: Event) => {
e.stopPropagation();
});
// Don't let the pointerdown event get to the workspace.
browserEvents.conditionalBind(textArea, 'pointerdown', this, (e: Event) => {
e.stopPropagation();
touch.clearTouchIdentifier();
});
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
}

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
export {CommentEditor} from './comments/comment_editor.js';
export {CommentView} from './comments/comment_view.js';
export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
export {WorkspaceComment} from './comments/workspace_comment.js';

View File

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

View File

@@ -6,6 +6,7 @@
import * as browserEvents from '../browser_events.js';
import * as css from '../css.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node';
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
import * as layers from '../layers.js';
import * as touch from '../touch.js';
@@ -15,6 +16,7 @@ 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 {CommentEditor} from './comment_editor.js';
export class CommentView implements IRenderedElement {
/** The root group element of the comment view. */
@@ -46,11 +48,8 @@ export class CommentView implements IRenderedElement {
/** The resize handle element. */
private resizeHandle: SVGImageElement;
/** The foreignObject containing the HTML text area. */
private foreignObject: SVGForeignObjectElement;
/** The text area where the user can type. */
private textArea: HTMLTextAreaElement;
/** The part of the comment view that contains the textarea to edit the comment. */
private commentEditor: CommentEditor;
/** The current size of the comment in workspace units. */
private size: Size;
@@ -64,14 +63,6 @@ export class CommentView implements IRenderedElement {
/** The current location of the comment in workspace coordinates. */
private location: Coordinate = new Coordinate(0, 0);
/** The current text of the comment. Updates on text area change. */
private text: string = '';
/** Listeners for changes to text. */
private textChangeListeners: Array<
(oldText: string, newText: string) => void
> = [];
/** Listeners for changes to size. */
private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> =
[];
@@ -106,7 +97,10 @@ export class CommentView implements IRenderedElement {
/** The default size of newly created comments. */
static defaultCommentSize = new Size(120, 100);
constructor(readonly workspace: WorkspaceSvg) {
constructor(
readonly workspace: WorkspaceSvg,
private commentId?: string,
) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
});
@@ -122,8 +116,7 @@ export class CommentView implements IRenderedElement {
textPreviewNode: this.textPreviewNode,
} = this.createTopBar(this.svgRoot, workspace));
({foreignObject: this.foreignObject, textArea: this.textArea} =
this.createTextArea(this.svgRoot));
this.commentEditor = this.createTextArea();
this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace);
@@ -236,33 +229,32 @@ export class CommentView implements IRenderedElement {
/**
* Creates the text area where users can type. Registers event listeners.
*/
private createTextArea(svgRoot: SVGGElement): {
foreignObject: SVGForeignObjectElement;
textArea: HTMLTextAreaElement;
} {
const foreignObject = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
'class': 'blocklyCommentForeignObject',
},
svgRoot,
private createTextArea() {
// When the user is done editing comment, focus the entire comment.
const onFinishEditing = () => this.svgRoot.focus();
const commentEditor = new CommentEditor(
this.workspace,
this.commentId,
onFinishEditing,
);
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
dom.addClass(textArea, 'blocklyCommentText');
dom.addClass(textArea, 'blocklyTextarea');
dom.addClass(textArea, 'blocklyText');
body.appendChild(textArea);
foreignObject.appendChild(body);
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
this.svgRoot.appendChild(commentEditor.getDom());
return {foreignObject, textArea};
commentEditor.addTextChangeListener((oldText, newText) => {
this.updateTextPreview(newText);
// Update size in case our minimum size increased.
this.setSize(this.size);
});
return commentEditor;
}
/**
*
* @returns The FocusableNode representing the editor portion of this comment.
*/
getEditorFocusableNode(): IFocusableNode {
return this.commentEditor;
}
/** Creates the DOM elements for the comment resize handle. */
@@ -324,7 +316,7 @@ export class CommentView implements IRenderedElement {
this.updateHighlightRect(size);
this.updateTopBarSize(size);
this.updateTextAreaSize(size, topBarSize);
this.commentEditor.updateSize(size, topBarSize);
this.updateDeleteIconPosition(size, topBarSize, deleteSize);
this.updateFoldoutIconPosition(topBarSize, foldoutSize);
this.updateTextPreviewSize(
@@ -360,7 +352,7 @@ export class CommentView implements IRenderedElement {
foldoutSize: Size,
deleteSize: Size,
): Size {
this.updateTextPreview(this.textArea.value ?? '');
this.updateTextPreview(this.commentEditor.getText() ?? '');
const textPreviewWidth = dom.getTextWidth(this.textPreview);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
@@ -408,19 +400,6 @@ export class CommentView implements IRenderedElement {
this.topBarBackground.setAttribute('width', `${size.width}`);
}
/** Updates the size of the text area elements to reflect the new size. */
private updateTextAreaSize(size: Size, topBarSize: Size) {
this.foreignObject.setAttribute(
'height',
`${size.height - topBarSize.height}`,
);
this.foreignObject.setAttribute('width', `${size.width}`);
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
if (this.workspace.RTL) {
this.foreignObject.setAttribute('x', `${-size.width}`);
}
}
/**
* Updates the position of the delete icon elements to reflect the new size.
*/
@@ -652,12 +631,11 @@ export class CommentView implements IRenderedElement {
if (this.editable) {
dom.addClass(this.svgRoot, 'blocklyEditable');
dom.removeClass(this.svgRoot, 'blocklyReadonly');
this.textArea.removeAttribute('readonly');
} else {
dom.removeClass(this.svgRoot, 'blocklyEditable');
dom.addClass(this.svgRoot, 'blocklyReadonly');
this.textArea.setAttribute('readonly', 'true');
}
this.commentEditor.setEditable(editable);
}
/** Returns the current location of the comment in workspace coordinates. */
@@ -678,49 +656,29 @@ export class CommentView implements IRenderedElement {
);
}
/** Retursn the current text of the comment. */
/** Returns the current text of the comment. */
getText() {
return this.text;
return this.commentEditor.getText();
}
/** Sets the current text of the comment. */
setText(text: string) {
this.textArea.value = text;
this.onTextChange();
this.commentEditor.setText(text);
}
/** Sets the placeholder text displayed for an empty comment. */
setPlaceholderText(text: string) {
this.textArea.placeholder = text;
this.commentEditor.setPlaceholderText(text);
}
/** Registers a callback that listens for text changes. */
/** Registers a callback that listens for text changes on the comment editor. */
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
this.textChangeListeners.push(listener);
this.commentEditor.addTextChangeListener(listener);
}
/** Removes the given listener from the list of text change listeners. */
/** Removes the given listener from the comment editor. */
removeTextChangeListener(listener: () => void) {
this.textChangeListeners.splice(
this.textChangeListeners.indexOf(listener),
1,
);
}
/**
* Triggers listeners when the text of the comment changes, either
* programmatically or manually by the user.
*/
private onTextChange() {
const oldText = this.text;
this.text = this.textArea.value;
this.updateTextPreview(this.text);
// Update size in case our minimum size increased.
this.setSize(this.size);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
this.textChangeListeners[i](oldText, this.text);
}
this.commentEditor.removeTextChangeListener(listener);
}
/** Updates the preview text element to reflect the given text. */
@@ -884,6 +842,11 @@ css.register(`
fill: none;
}
.blocklyCommentText.blocklyActiveFocus {
border-color: #fc3;
border-width: 2px;
}
.blocklySelected .blocklyCommentHighlight {
stroke: #fc3;
stroke-width: 3px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
* @returns The first field or input of the given block, if any.
*/
getFirstChild(current: BlockSvg): IFocusableNode | null {
const candidates = getBlockNavigationCandidates(current);
const candidates = getBlockNavigationCandidates(current, true);
return candidates[0];
}
@@ -58,6 +58,8 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
return current.nextConnection?.targetBlock();
} else if (current.outputConnection?.targetBlock()) {
return navigateBlock(current, 1);
} else if (current.getSurroundParent()) {
return navigateBlock(current.getTopStackBlock(), 1);
} else if (this.getParent(current) instanceof WorkspaceSvg) {
return navigateStacks(current, 1);
}
@@ -111,14 +113,27 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
* @param block The block to retrieve the navigable children of.
* @returns A list of navigable/focusable children of the given block.
*/
function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] {
function getBlockNavigationCandidates(
block: BlockSvg,
forward: boolean,
): IFocusableNode[] {
const candidates: IFocusableNode[] = block.getIcons();
for (const input of block.inputList) {
if (!input.isVisible()) continue;
candidates.push(...input.fieldRow);
if (input.connection?.targetBlock()) {
candidates.push(input.connection.targetBlock() as BlockSvg);
const connectedBlock = input.connection.targetBlock() as BlockSvg;
if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
const lastStackBlock = connectedBlock
.lastConnectionInStack(false)
?.getSourceBlock();
if (lastStackBlock) {
candidates.push(lastStackBlock);
}
} else {
candidates.push(connectedBlock);
}
} else if (input.connection?.type === ConnectionType.INPUT_VALUE) {
candidates.push(input.connection as RenderedConnection);
}
@@ -174,11 +189,11 @@ export function navigateBlock(
): IFocusableNode | null {
const block =
current instanceof BlockSvg
? current.outputConnection.targetBlock()
? (current.outputConnection?.targetBlock() ?? current.getSurroundParent())
: current.getSourceBlock();
if (!(block instanceof BlockSvg)) return null;
const candidates = getBlockNavigationCandidates(block);
const candidates = getBlockNavigationCandidates(block, delta > 0);
const currentIndex = candidates.indexOf(current);
if (currentIndex === -1) return null;

View File

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

View File

@@ -17,7 +17,6 @@ import {BlockSvg} from '../block_svg.js';
import {Field} from '../field.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {Marker} from './marker.js';
@@ -374,17 +373,8 @@ export class LineCursor extends Marker {
*
* @returns The current field, connection, or block the cursor is on.
*/
override getCurNode(): IFocusableNode | null {
// Ensure the current node matches what's currently focused.
const focused = getFocusManager().getFocusedNode();
const block = this.getSourceBlockFromNode(focused);
if (block && block.workspace === this.workspace) {
// If the current focused node corresponds to a block then ensure that it
// belongs to the correct workspace for this cursor.
this.setCurNode(focused);
}
return super.getCurNode();
getCurNode(): IFocusableNode | null {
return getFocusManager().getFocusedNode();
}
/**
@@ -395,12 +385,8 @@ export class LineCursor extends Marker {
*
* @param newNode The new location of the cursor.
*/
override setCurNode(newNode: IFocusableNode | null) {
super.setCurNode(newNode);
if (isFocusableNode(newNode)) {
getFocusManager().focusNode(newNode);
}
setCurNode(newNode: IFocusableNode) {
getFocusManager().focusNode(newNode);
// Try to scroll cursor into view.
if (newNode instanceof BlockSvg) {

View File

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

View File

@@ -64,9 +64,8 @@ export class Navigator {
getFirstChild(current: IFocusableNode): IFocusableNode | null {
const result = this.get(current)?.getFirstChild(current);
if (!result) return null;
// If the child isn't navigable, don't traverse into it; check its peers.
if (!this.get(result)?.isNavigable(result)) {
return this.getNextSibling(result);
return this.getFirstChild(result) || this.getNextSibling(result);
}
return result;
}

View File

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

View File

@@ -22,6 +22,7 @@ import type {Block} from './block.js';
import type {BlockSvg} from './block_svg.js';
import type {BlocklyOptions} from './blockly_options.js';
import * as browserEvents from './browser_events.js';
import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js';
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
import {WorkspaceComment} from './comments/workspace_comment.js';
import * as common from './common.js';
@@ -41,6 +42,7 @@ import type {FlyoutButton} from './flyout_button.js';
import {getFocusManager} from './focus_manager.js';
import {Gesture} from './gesture.js';
import {Grid} from './grid.js';
import {MutatorIcon} from './icons/mutator_icon.js';
import {isAutoHideable} from './interfaces/i_autohideable.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import {IContextMenu} from './interfaces/i_contextmenu.js';
@@ -2680,7 +2682,7 @@ export class WorkspaceSvg
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
return this;
return (this.isMutator && this.options.parentWorkspace) || this;
}
/** See IFocusableNode.onNodeFocus. */
@@ -2710,7 +2712,42 @@ export class WorkspaceSvg
/** See IFocusableTree.getNestedTrees. */
getNestedTrees(): Array<IFocusableTree> {
return [];
const nestedWorkspaces = this.getAllBlocks()
.map((block) => block.getIcons())
.flat()
.filter(
(icon): icon is MutatorIcon =>
icon instanceof MutatorIcon && icon.bubbleIsVisible(),
)
.map((icon) => icon.getBubble()?.getWorkspace())
.filter((workspace) => !!workspace);
const ownFlyout = this.getFlyout(true);
if (ownFlyout) {
nestedWorkspaces.push(ownFlyout.getWorkspace());
}
return nestedWorkspaces;
}
/**
* Used for searching for a specific workspace comment.
* We can't use this.getWorkspaceCommentById because the workspace
* comment ids might not be globally unique, but the id assigned to
* the focusable element for the comment should be.
*/
private searchForWorkspaceComment(
id: string,
): RenderedWorkspaceComment | undefined {
for (const comment of this.getTopComments()) {
if (
comment instanceof RenderedWorkspaceComment &&
comment.canBeFocused() &&
comment.getFocusableElement().id === id
) {
return comment;
}
}
}
/** See IFocusableTree.lookUpFocusableNode. */
@@ -2757,21 +2794,29 @@ 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);
const comment = this.searchForWorkspaceComment(commentId);
if (comment) {
return comment.getEditorFocusableNode();
}
}
// Search for a specific block.
// Don't use `getBlockById` because the block ID is not guaranteeed
// to be globally unique, but the ID on the focusable element is.
const block = this.getAllBlocks(false).find(
(block) => block.getFocusableElement().id === id,
);
if (block) return block;
// Search for a workspace comment (semi-expensive).
for (const comment of this.getTopComments()) {
if (
comment instanceof RenderedWorkspaceComment &&
comment.canBeFocused() &&
comment.getFocusableElement().id === id
) {
return comment;
}
const comment = this.searchForWorkspaceComment(id);
if (comment) {
return comment;
}
// Search for icons and bubbles (which requires an expensive getAllBlocks).

View File

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

View File

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

View File

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

284
package-lock.json generated
View File

@@ -101,17 +101,17 @@
}
},
"node_modules/@blockly/dev-tools": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.0.tgz",
"integrity": "sha512-c2JJbj5Q9mGdy0iUvE5OBOl1zmSMJrSokORgnmrhxGCiJ6QexPGCsi1QAn6uzpUtGKjhpnEAQ6+jX7ROZe7QQg==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.1.tgz",
"integrity": "sha512-OnY24Up00owts0VtOaokUmOQdzH+K1PNcr3LC3huwa9PO0TlKiXTq4V5OuIqBS++enyj93gXQ8PhvFGudkogTQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@blockly/block-test": "^7.0.0",
"@blockly/theme-dark": "^8.0.0",
"@blockly/theme-deuteranopia": "^7.0.0",
"@blockly/theme-highcontrast": "^7.0.0",
"@blockly/theme-tritanopia": "^7.0.0",
"@blockly/block-test": "^7.0.1",
"@blockly/theme-dark": "^8.0.1",
"@blockly/theme-deuteranopia": "^7.0.1",
"@blockly/theme-highcontrast": "^7.0.1",
"@blockly/theme-tritanopia": "^7.0.1",
"chai": "^4.2.0",
"dat.gui": "^0.7.7",
"lodash.assign": "^4.2.0",
@@ -127,9 +127,9 @@
}
},
"node_modules/@blockly/dev-tools/node_modules/@blockly/block-test": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.0.tgz",
"integrity": "sha512-Y+Iwg1hHmOaqXveTOiZNXHH+jNBP+LC5L8ZxKKWeO8aB9DZD5G2hgApHfLaxeZzqnCl8zspvGnrrlFy9foEdWw==",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.1.tgz",
"integrity": "sha512-w91ZZbpJDKGQJVO7gKqQaM17ffcsW1ktrnSTz/OpDw5R4H+1q05NgWO5gYzGPzLfFdvPcrkc0v00KhD4UG7BRA==",
"dev": true,
"license": "Apache 2.0",
"engines": {
@@ -209,9 +209,9 @@
}
},
"node_modules/@blockly/theme-dark": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-8.0.0.tgz",
"integrity": "sha512-Fq8ifjCwbJW305Su7SNBP8jXs4h1hp2EdQ9cMGOCr/racRIYfDRRBqjy0ZRLLqI7BsgZKxKy6Aa+OjgWEKeKfw==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-8.0.1.tgz",
"integrity": "sha512-0Di3WIUwCVQw7jK9myUf/J+4oHLADWc8YxeF40KQgGsyulVrVnYipwtBolj+wxq2xjxIkqgvctAN3BdvM4mynA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -222,9 +222,9 @@
}
},
"node_modules/@blockly/theme-deuteranopia": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-7.0.0.tgz",
"integrity": "sha512-zKhlnD/AF3MR9+Rlwus3vAPq8gwCZaZ08VEupvz5b98mk36suRlIrQanM8HVLGcozxiEvUNrTNOGO5kj8PeTWA==",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-7.0.1.tgz",
"integrity": "sha512-V05Hk2hzQZict47LfzDdSTP+J5HlYiF7de/8LR/bsRQB/ft7UUTraqDLIivYc9gL2alsVtKzq/yFs9wi7FMAqQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -235,9 +235,9 @@
}
},
"node_modules/@blockly/theme-highcontrast": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-7.0.0.tgz",
"integrity": "sha512-6Apkw5iUlOq1DoOJgwsfo8Iha2OkxXMSNHqb8ZVVmUhCHjce0XMXgq1Rqty/2l/C2AKB+WWLZEWxOyGWYrQViQ==",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-7.0.1.tgz",
"integrity": "sha512-dMhysbXf8QtHxuhI1EY5GdZErlfEhjpCogwfzglDKSu8MF2C+5qzOQBxKmqfnEYJl6G9B2HNGw+mEaUo8oel6Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -260,9 +260,9 @@
}
},
"node_modules/@blockly/theme-tritanopia": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-7.0.0.tgz",
"integrity": "sha512-22TFAuY8ilKsQomDC8GXMHsCfdR8l75yPPFl6AOCcok2FJLkiyhjGpAy2cNexA9P2xP/rW7vdsG3wC8ukWihUA==",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-7.0.1.tgz",
"integrity": "sha512-eLqPCmW6xvSYvyTFFE5uz0Bw806LxOmaQrCOzbUywkT41s2ITP06OP1BVQrHdkZSt5whipZYpB1RMGxYxS/Bpw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -383,17 +383,20 @@
}
},
"node_modules/@es-joy/jsdoccomment": {
"version": "0.49.0",
"resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz",
"integrity": "sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==",
"version": "0.50.2",
"resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz",
"integrity": "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6",
"@typescript-eslint/types": "^8.11.0",
"comment-parser": "1.4.1",
"esquery": "^1.6.0",
"jsdoc-type-pratt-parser": "~4.1.0"
},
"engines": {
"node": ">=16"
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
@@ -714,10 +717,11 @@
}
},
"node_modules/@hyperjump/browser": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.1.6.tgz",
"integrity": "sha512-i27uPV7SxK1GOn7TLTRxTorxchYa5ur9JHgtl6TxZ1MHuyb9ROAnXxEeu4q4H1836Xb7lL2PGPsaa5Jl3p+R6g==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.3.1.tgz",
"integrity": "sha512-Le5XZUjnVqVjkgLYv6yyWgALat/0HpB1XaCPuCZ+GCFki9NvXloSZITIJ0H+wRW7mb9At1SxvohKBbNQbrr/cw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@hyperjump/json-pointer": "^1.1.0",
"@hyperjump/uri": "^1.2.0",
@@ -743,9 +747,9 @@
}
},
"node_modules/@hyperjump/json-schema": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.11.0.tgz",
"integrity": "sha512-gX1YNObOybUW6tgJjvb1lomNbI/VnY+EBPokmEGy9Lk8cgi+gE0vXhX1XDgIpUUA4UXfgHEn5I1mga5vHgOttg==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.15.1.tgz",
"integrity": "sha512-/NtriODPtJ+4nqewSksw3YtcINXy1C2TraFuhah/IfSdwgBUas0XNCHJz9mXcniR7/2nCUSFMZg9A3wKo3i0iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1212,15 +1216,16 @@
}
},
"node_modules/@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz",
"integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@promptbook/utils": {
@@ -3176,6 +3181,7 @@
"resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz",
"integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12.0.0"
}
@@ -3693,9 +3699,10 @@
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
@@ -3834,10 +3841,11 @@
}
},
"node_modules/diff": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
@@ -4088,12 +4096,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
"dev": true
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -4301,23 +4303,22 @@
}
},
"node_modules/eslint-plugin-jsdoc": {
"version": "50.6.9",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.6.9.tgz",
"integrity": "sha512-7/nHu3FWD4QRG8tCVqcv+BfFtctUtEDWc29oeDXB4bwmDM2/r1ndl14AG/2DUntdqH7qmpvdemJKwb3R97/QEw==",
"version": "50.7.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.7.1.tgz",
"integrity": "sha512-XBnVA5g2kUVokTNUiE1McEPse5n9/mNUmuJcx52psT6zBs2eVcXSmQBvjfa7NZdfLVSy3u1pEDDUxoxpwy89WA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@es-joy/jsdoccomment": "~0.49.0",
"@es-joy/jsdoccomment": "~0.50.2",
"are-docs-informative": "^0.0.2",
"comment-parser": "1.4.1",
"debug": "^4.3.6",
"debug": "^4.4.1",
"escape-string-regexp": "^4.0.0",
"espree": "^10.1.0",
"espree": "^10.3.0",
"esquery": "^1.6.0",
"parse-imports": "^2.1.1",
"semver": "^7.6.3",
"spdx-expression-parse": "^4.0.0",
"synckit": "^0.9.1"
"parse-imports-exports": "^0.2.4",
"semver": "^7.7.2",
"spdx-expression-parse": "^4.0.0"
},
"engines": {
"node": ">=18"
@@ -4327,10 +4328,11 @@
}
},
"node_modules/eslint-plugin-jsdoc/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -4349,14 +4351,14 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.0.tgz",
"integrity": "sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==",
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.0.tgz",
"integrity": "sha512-8qsOYwkkGrahrgoUv76NZi23koqXOGiiEzXMrT8Q7VcYaUISR+5MorIUxfWqYXN0fN/31WbSrxCxFkVQ43wwrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.11.0"
"synckit": "^0.11.7"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -4379,43 +4381,6 @@
}
}
},
"node_modules/eslint-plugin-prettier/node_modules/@pkgr/core": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.4.tgz",
"integrity": "sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/eslint-plugin-prettier/node_modules/synckit": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.4.tgz",
"integrity": "sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.3",
"tslib": "^2.8.1"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/eslint-plugin-prettier/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/eslint-scope": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
@@ -5476,9 +5441,9 @@
}
},
"node_modules/globals": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz",
"integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==",
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz",
"integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6661,6 +6626,7 @@
"resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz",
"integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
@@ -7298,29 +7264,29 @@
}
},
"node_modules/mocha": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-11.3.0.tgz",
"integrity": "sha512-J0RLIM89xi8y6l77bgbX+03PeBRDQCOVQpnwOcCN7b8hCmbh6JvGI2ZDJ5WMoHz+IaPU+S4lvTd0j51GmBAdgQ==",
"version": "11.7.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.0.tgz",
"integrity": "sha512-bXfLy/mI8n4QICg+pWj1G8VduX5vC0SHRwFpiR5/Fxc8S2G906pSfkyMmHVsdJNQJQNh3LE67koad9GzEvkV6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"browser-stdout": "^1.3.1",
"chokidar": "^4.0.1",
"debug": "^4.3.5",
"diff": "^5.2.0",
"diff": "^7.0.0",
"escape-string-regexp": "^4.0.0",
"find-up": "^5.0.0",
"glob": "^10.4.5",
"he": "^1.2.0",
"js-yaml": "^4.1.0",
"log-symbols": "^4.1.0",
"minimatch": "^5.1.6",
"minimatch": "^9.0.5",
"ms": "^2.1.3",
"picocolors": "^1.1.1",
"serialize-javascript": "^6.0.2",
"strip-json-comments": "^3.1.1",
"supports-color": "^8.1.1",
"workerpool": "^6.5.1",
"workerpool": "^9.2.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1",
"yargs-unparser": "^2.0.0"
@@ -7380,22 +7346,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mocha/node_modules/glob/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mocha/node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -7420,16 +7370,19 @@
"license": "ISC"
},
"node_modules/mocha/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mocha/node_modules/path-scurry": {
@@ -7855,17 +7808,14 @@
"node": ">=0.8"
}
},
"node_modules/parse-imports": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/parse-imports/-/parse-imports-2.2.1.tgz",
"integrity": "sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==",
"node_modules/parse-imports-exports": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz",
"integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-module-lexer": "^1.5.3",
"slashes": "^3.0.12"
},
"engines": {
"node": ">= 18"
"parse-statements": "1.0.11"
}
},
"node_modules/parse-node-version": {
@@ -7886,6 +7836,13 @@
"node": ">=0.10.0"
}
},
"node_modules/parse-statements": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz",
"integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==",
"dev": true,
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
@@ -8254,9 +8211,9 @@
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.0.tgz",
"integrity": "sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -9186,12 +9143,6 @@
"node": ">=0.3.1"
}
},
"node_modules/slashes": {
"version": "3.0.12",
"resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz",
"integrity": "sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==",
"dev": true
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@@ -9566,31 +9517,25 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
},
"node_modules/synckit": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz",
"integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==",
"version": "0.11.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz",
"integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.1.0",
"tslib": "^2.6.2"
"@pkgr/core": "^0.2.4"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
"url": "https://opencollective.com/synckit"
}
},
"node_modules/synckit/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
},
"node_modules/tar-fs": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz",
"integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10357,10 +10302,11 @@
}
},
"node_modules/workerpool": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz",
"integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==",
"dev": true
"version": "9.3.2",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.2.tgz",
"integrity": "sha512-Xz4Nm9c+LiBHhDR5bDLnNzmj6+5F+cyEAWPMkbs2awq/dYazR/efelZzUAjB/y3kNHL+uzkHvxVVpaOfGCPV7A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",

View File

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

View File

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

View File

@@ -47,6 +47,16 @@ suite('Keyboard Shortcut Items', function () {
.returns(block.nextConnection);
}
/**
* Creates a workspace comment and set it as the focused node.
* @param {Blockly.Workspace} workspace The workspace to create a new comment on.
*/
function setSelectedComment(workspace) {
const comment = workspace.newComment();
sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(comment);
return comment;
}
/**
* Creates a test for not running keyDown events when the workspace is in read only mode.
* @param {Object} keyEvent Mocked key down event. Use createKeyDownEvent.
@@ -173,12 +183,17 @@ suite('Keyboard Shortcut Items', function () {
});
});
});
// Do not copy a block if a workspace is in readonly mode.
suite('Not called when readOnly is true', function () {
// Allow copying a block if a workspace is in readonly mode.
suite('Called when readOnly is true', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
runReadOnlyTest(keyEvent, testCaseName);
test(testCaseName, function () {
this.workspace.setIsReadOnly(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
});
});
// Do not copy a block if a drag is in progress.
@@ -236,6 +251,152 @@ suite('Keyboard Shortcut Items', function () {
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
// Copy a comment.
test('Workspace comment', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
Blockly.getFocusManager().getFocusedNode.restore();
this.comment = setSelectedComment(this.workspace);
this.copySpy = sinon.spy(this.comment, 'toCopyData');
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
});
});
});
suite('Cut', function () {
setup(function () {
this.block = setSelectedBlock(this.workspace);
this.copySpy = sinon.spy(this.block, 'toCopyData');
this.disposeSpy = sinon.spy(this.block, 'dispose');
this.hideChaffSpy = sinon.spy(
Blockly.WorkspaceSvg.prototype,
'hideChaff',
);
});
const testCases = [
[
'Control X',
createKeyDownEvent(Blockly.utils.KeyCodes.X, [
Blockly.utils.KeyCodes.CTRL,
]),
],
[
'Meta X',
createKeyDownEvent(Blockly.utils.KeyCodes.X, [
Blockly.utils.KeyCodes.META,
]),
],
];
// Cut a block.
suite('Simple', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.disposeSpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
});
});
// Do not cut a block if a workspace is in readonly mode.
suite('Not called when readOnly is true', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
this.workspace.setIsReadOnly(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if a drag is in progress.
suite('Drag in progress', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon.stub(this.workspace, 'isDragging').returns(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if is is not deletable.
suite('Block is not deletable', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon
.stub(Blockly.common.getSelected(), 'isOwnDeletable')
.returns(false);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if it is not movable.
suite('Block is not movable', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon
.stub(Blockly.common.getSelected(), 'isOwnMovable')
.returns(false);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
test('Not called when connection is focused', function () {
// Restore the stub behavior called during setup
Blockly.getFocusManager().getFocusedNode.restore();
setSelectedConnection(this.workspace);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [
Blockly.utils.KeyCodes.CTRL,
]);
this.injectionDiv.dispatchEvent(event);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
// Cut a comment.
suite('Workspace comment', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
Blockly.getFocusManager().getFocusedNode.restore();
this.comment = setSelectedComment(this.workspace);
this.copySpy = sinon.spy(this.comment, 'toCopyData');
this.disposeSpy = sinon.spy(this.comment, 'dispose');
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.disposeSpy);
});
});
});
});
suite('Undo', function () {

View File

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