release: Merge branch 'develop' into rc/v12.3.0

This commit is contained in:
Aaron Dodson
2025-08-18 14:29:52 -07:00
41 changed files with 1539 additions and 354 deletions

View File

@@ -36,13 +36,13 @@ jobs:
needs: prepare
steps:
- name: Download prepared files
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: appengine_files
path: _deploy/
- name: Deploy to App Engine
uses: google-github-actions/deploy-appengine@v2.1.5
uses: google-github-actions/deploy-appengine@v2.1.7
# For parameters see:
# https://github.com/google-github-actions/deploy-appengine#inputs
with:

View File

@@ -5,13 +5,15 @@ name: Run browser manually
on:
workflow_dispatch:
schedule:
- cron: '0 6 * * 1' # Runs every Monday at 06:00 UTC
permissions:
contents: read
jobs:
build:
timeout-minutes: 10
timeout-minutes: 120
runs-on: ${{ matrix.os }}
strategy:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {Rect} from '../utils/rect.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
import type {CommentView} from './comment_view.js';
/**
* Button displayed on a comment's top bar.
@@ -29,6 +29,7 @@ export abstract class CommentBarButton implements IFocusableNode {
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
protected readonly commentView: CommentView,
) {}
/**
@@ -39,17 +40,10 @@ export abstract class CommentBarButton implements IFocusableNode {
}
/**
* Returns the parent comment of this comment bar button.
* Returns the parent comment view of this comment bar button.
*/
getParentComment(): RenderedWorkspaceComment {
const comment = this.workspace.getCommentById(this.id);
if (!comment) {
throw new Error(
`Comment bar button ${this.id} has no corresponding comment`,
);
}
return comment;
getCommentView(): CommentView {
return this.commentView;
}
/** Adjusts the position of this button within its parent container. */

View File

@@ -53,6 +53,7 @@ export class CommentEditor implements IFocusableNode {
'textarea',
) as HTMLTextAreaElement;
this.textArea.setAttribute('tabindex', '-1');
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
dom.addClass(this.textArea, 'blocklyCommentText');
dom.addClass(this.textArea, 'blocklyTextarea');
dom.addClass(this.textArea, 'blocklyText');
@@ -86,6 +87,11 @@ export class CommentEditor implements IFocusableNode {
},
);
// Don't zoom with mousewheel; let it scroll instead.
browserEvents.conditionalBind(this.textArea, 'wheel', this, (e: Event) => {
e.stopPropagation();
});
// Register listener for keydown events that would finish editing.
browserEvents.conditionalBind(
this.textArea,

View File

@@ -102,7 +102,7 @@ export class CommentView implements IRenderedElement {
constructor(
readonly workspace: WorkspaceSvg,
private commentId: string,
readonly commentId: string,
) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
@@ -176,12 +176,18 @@ export class CommentView implements IRenderedElement {
this.commentId,
this.workspace,
topBarGroup,
this,
);
const foldoutButton = new CollapseCommentBarButton(
this.commentId,
this.workspace,
topBarGroup,
this,
);
this.addDisposeListener(() => {
deleteButton.dispose();
foldoutButton.dispose();
});
const textPreview = dom.createSvgElement(
Svg.TEXT,
{
@@ -612,13 +618,12 @@ export class CommentView implements IRenderedElement {
/** Disposes of this comment view. */
dispose() {
this.disposing = true;
this.foldoutButton.dispose();
this.deleteButton.dispose();
dom.removeNode(this.svgRoot);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
this.disposeListeners[i]();
}
this.disposeListeners.length = 0;
this.disposed = true;
}

View File

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

View File

@@ -74,15 +74,6 @@ export class RenderedWorkspaceComment
this,
this.startGesture,
);
// Don't zoom with mousewheel; let it scroll instead.
browserEvents.conditionalBind(
this.view.getSvgRoot(),
'wheel',
this,
(e: Event) => {
e.stopPropagation();
},
);
}
/**
@@ -289,6 +280,7 @@ export class RenderedWorkspaceComment
paster: WorkspaceCommentPaster.TYPE,
commentState: commentSerialization.save(this, {
addCoordinates: true,
saveIds: false,
}),
};
}

View File

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

View File

@@ -241,7 +241,7 @@ let content = `
cursor: default;
}
.blocklyIconGroup:not(:hover),
.blocklyIconGroup:not(:hover):not(:focus),
.blocklyIconGroupReadonly {
opacity: .6;
}

View File

@@ -699,25 +699,30 @@ export class FieldDropdown extends Field<string> {
prefix?: string;
suffix?: string;
} {
let hasImages = false;
let hasNonTextContent = false;
const trimmedOptions = options.map((option): MenuOption => {
if (option === FieldDropdown.SEPARATOR) return option;
if (option === FieldDropdown.SEPARATOR) {
hasNonTextContent = true;
return option;
}
const [label, value] = option;
if (typeof label === 'string') {
return [parsing.replaceMessageReferences(label), value];
}
hasImages = true;
hasNonTextContent = true;
// Copy the image properties so they're not influenced by the original.
// NOTE: No need to deep copy since image properties are only 1 level deep.
const imageLabel = isImageProperties(label)
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
: {...label};
: label;
return [imageLabel, value];
});
if (hasImages || options.length < 2) return {options: trimmedOptions};
if (hasNonTextContent || options.length < 2) {
return {options: trimmedOptions};
}
const stringOptions = trimmedOptions as [string, string][];
const stringLabels = stringOptions.map(([label]) => label);
@@ -793,7 +798,7 @@ export class FieldDropdown extends Field<string> {
} else if (typeof option[1] !== 'string') {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
Found ${option[1]} in: ${option}`,
);
} else if (
@@ -806,7 +811,7 @@ export class FieldDropdown extends Field<string> {
) {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option must have a string
`Invalid option[${i}]: Each FieldDropdown option must have a string
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,11 +14,13 @@
*/
import {BlockSvg} from '../block_svg.js';
import {CommentBarButton} from '../comments/comment_bar_button.js';
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
import {Field} from '../field.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import {Rect} from '../utils/rect.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {Marker} from './marker.js';
@@ -403,6 +405,17 @@ export class LineCursor extends Marker {
);
} else if (newNode instanceof RenderedWorkspaceComment) {
newNode.workspace.scrollBoundsIntoView(newNode.getBoundingRectangle());
} else if (newNode instanceof CommentBarButton) {
const commentView = newNode.getCommentView();
const xy = commentView.getRelativeToSurfaceXY();
const size = commentView.getSize();
const bounds = new Rect(
xy.y,
xy.y + size.height,
xy.x,
xy.x + size.width,
);
commentView.workspace.scrollBoundsIntoView(bounds);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

463
package-lock.json generated
View File

@@ -13,24 +13,25 @@
"jsdom": "26.1.0"
},
"devDependencies": {
"@blockly/block-test": "^7.0.1",
"@blockly/dev-tools": "^9.0.0",
"@blockly/theme-modern": "^6.0.3",
"@blockly/block-test": "^7.0.2",
"@blockly/dev-tools": "^9.0.2",
"@blockly/theme-modern": "^7.0.1",
"@hyperjump/browser": "^1.1.4",
"@hyperjump/json-schema": "^1.5.0",
"@microsoft/api-documenter": "^7.22.4",
"@microsoft/api-documenter": "7.22.4",
"@microsoft/api-extractor": "^7.29.5",
"ajv": "^8.17.1",
"async-done": "^2.0.0",
"chai": "^5.1.1",
"concurrently": "^9.0.1",
"eslint": "^9.15.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-jsdoc": "^51.3.1",
"eslint-plugin-jsdoc": "^52.0.2",
"eslint-plugin-prettier": "^5.2.1",
"glob": "^11.0.1",
"globals": "^16.0.0",
"google-closure-compiler": "^20250625.0.0",
"google-closure-compiler": "^20250709.0.0",
"gulp": "^5.0.0",
"gulp-concat": "^2.6.1",
"gulp-gzip": "^1.4.2",
@@ -89,10 +90,11 @@
"license": "ISC"
},
"node_modules/@blockly/block-test": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.1.tgz",
"integrity": "sha512-w91ZZbpJDKGQJVO7gKqQaM17ffcsW1ktrnSTz/OpDw5R4H+1q05NgWO5gYzGPzLfFdvPcrkc0v00KhD4UG7BRA==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.2.tgz",
"integrity": "sha512-fwbJnMiH4EoX/CR0ZTGzSKaGfpRBn4nudquoWfvG4ekkhTjaNTldDdHvUSeyexzvwZZcT6M4I1Jtq3IoomTKEg==",
"dev": true,
"license": "Apache 2.0",
"engines": {
"node": ">=8.17.0"
},
@@ -101,13 +103,13 @@
}
},
"node_modules/@blockly/dev-tools": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.1.tgz",
"integrity": "sha512-OnY24Up00owts0VtOaokUmOQdzH+K1PNcr3LC3huwa9PO0TlKiXTq4V5OuIqBS++enyj93gXQ8PhvFGudkogTQ==",
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.2.tgz",
"integrity": "sha512-Ic/+BkqEvLRZxzNQVW/FKXx1cB042xXXPTSmNlTv2qr4oY+hN2fwBtHj3PirBWAzWgMOF8VDTj/EXL36jH1/lg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@blockly/block-test": "^7.0.1",
"@blockly/block-test": "^7.0.2",
"@blockly/theme-dark": "^8.0.1",
"@blockly/theme-deuteranopia": "^7.0.1",
"@blockly/theme-highcontrast": "^7.0.1",
@@ -235,15 +237,16 @@
}
},
"node_modules/@blockly/theme-modern": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/@blockly/theme-modern/-/theme-modern-6.0.10.tgz",
"integrity": "sha512-xOVf5Vq5ACgbVsaNAKWb5cE0msUfBxj1G1asp0aBmWo1QCr3Yze4rUtFDaNIoeCd8EsRpuWZgBYg74zPL9eAow==",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@blockly/theme-modern/-/theme-modern-7.0.1.tgz",
"integrity": "sha512-aMI3OBp8KCbLU1O14FLUlocK7IeMOyiSenlTJ4lwGcBmZntM2OIcx6o89oAIeq6HkmaH7vMlK+/AgqdB3k0y3A==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8.17.0"
},
"peerDependencies": {
"blockly": "^11.0.0"
"blockly": "^12.0.0"
}
},
"node_modules/@blockly/theme-tritanopia": {
@@ -485,6 +488,23 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -498,6 +518,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/js": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz",
@@ -912,17 +939,17 @@
}
},
"node_modules/@microsoft/api-documenter": {
"version": "7.26.26",
"resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.26.26.tgz",
"integrity": "sha512-085FwdwQcXGvwtMJFajwhu5eZOQ3PXsyLIoq3WXAQr/7M6Vn59GMGjuB/+lIXqmWKkxzeFAX5f9sKqr9X7zI3g==",
"version": "7.22.4",
"resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.22.4.tgz",
"integrity": "sha512-d4htEhBd8UkFKff/+/nAi/z7rrspm1DanFmsRHLUp4gKMo/8hYDH/IQBWB4r9X/8X72jCv3I++VVWAfichL1rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/api-extractor-model": "7.30.6",
"@microsoft/tsdoc": "~0.15.1",
"@rushstack/node-core-library": "5.13.1",
"@rushstack/terminal": "0.15.3",
"@rushstack/ts-command-line": "5.0.1",
"@microsoft/api-extractor-model": "7.26.8",
"@microsoft/tsdoc": "0.14.2",
"@rushstack/node-core-library": "3.58.0",
"@rushstack/ts-command-line": "4.13.2",
"colors": "~1.2.1",
"js-yaml": "~3.13.1",
"resolve": "~1.22.1"
},
@@ -930,6 +957,106 @@
"api-documenter": "bin/api-documenter"
}
},
"node_modules/@microsoft/api-documenter/node_modules/@microsoft/api-extractor-model": {
"version": "7.26.8",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.26.8.tgz",
"integrity": "sha512-ESj3bBJkiMg/8tS0PW4+2rUgTVwOEfy41idTnFgdbVX+O50bN6S99MV6FIPlCZWCnRDcBfwxRXLdAkOQQ0JqGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.14.2",
"@microsoft/tsdoc-config": "~0.16.1",
"@rushstack/node-core-library": "3.58.0"
}
},
"node_modules/@microsoft/api-documenter/node_modules/@microsoft/tsdoc": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz",
"integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==",
"dev": true,
"license": "MIT"
},
"node_modules/@microsoft/api-documenter/node_modules/@microsoft/tsdoc-config": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz",
"integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.14.2",
"ajv": "~6.12.6",
"jju": "~1.4.0",
"resolve": "~1.19.0"
}
},
"node_modules/@microsoft/api-documenter/node_modules/@microsoft/tsdoc-config/node_modules/resolve": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz",
"integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.1.0",
"path-parse": "^1.0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/@microsoft/api-documenter/node_modules/@rushstack/node-core-library": {
"version": "3.58.0",
"resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.58.0.tgz",
"integrity": "sha512-DHAZ3LTOEq2/EGURznpTJDnB3SNE2CKMDXuviQ6afhru6RykE3QoqXkeyjbpLb5ib5cpIRCPE/wykNe0xmQj3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"colors": "~1.2.1",
"fs-extra": "~7.0.1",
"import-lazy": "~4.0.0",
"jju": "~1.4.0",
"resolve": "~1.22.1",
"semver": "~7.3.0",
"z-schema": "~5.0.2"
},
"peerDependencies": {
"@types/node": "*"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@microsoft/api-documenter/node_modules/@rushstack/ts-command-line": {
"version": "4.13.2",
"resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.13.2.tgz",
"integrity": "sha512-bCU8qoL9HyWiciltfzg7GqdfODUeda/JpI0602kbN5YH22rzTxyqYvv7aRLENCM7XCQ1VRs7nMkEqgJUOU8Sag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/argparse": "1.0.38",
"argparse": "~1.0.9",
"colors": "~1.2.1",
"string-argv": "~0.3.1"
}
},
"node_modules/@microsoft/api-documenter/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@microsoft/api-documenter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -952,6 +1079,29 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@microsoft/api-documenter/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/@microsoft/api-documenter/node_modules/semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@microsoft/api-extractor": {
"version": "7.52.8",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.8.tgz",
@@ -1056,12 +1206,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@microsoft/tsdoc-config/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1236,13 +1380,6 @@
"node": ">=14.14"
}
},
"node_modules/@rushstack/node-core-library/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/@rushstack/rig-package": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.3.tgz",
@@ -1955,16 +2092,16 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@@ -2002,28 +2139,6 @@
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.14.0.tgz",
"integrity": "sha512-oYs1UUtO97ZO2lJ4bwnWeQW8/zvOIQLGKcvPTsWmvc2SYgBb+upuNS5NxoLaMU4h8Ju3Nbj6Cq8mD2LQoqVKFA==",
"dev": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.4.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
"node_modules/ansi-gray": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz",
@@ -2766,9 +2881,9 @@
}
},
"node_modules/chai": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz",
"integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2779,7 +2894,7 @@
"pathval": "^2.0.0"
},
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/chalk": {
@@ -2979,6 +3094,16 @@
"color-support": "bin.js"
}
},
"node_modules/colors": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.2.5.tgz",
"integrity": "sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
@@ -3114,10 +3239,11 @@
}
},
"node_modules/concurrently": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz",
"integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==",
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz",
"integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"lodash": "^4.17.21",
@@ -3964,10 +4090,11 @@
}
},
"node_modules/eslint-plugin-jsdoc": {
"version": "51.3.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-51.3.1.tgz",
"integrity": "sha512-9v/e6XyrLf1HIs/uPCgm3GcUpH4BeuGVZJk7oauKKyS7su7d5Q6zx4Fq6TiYh+w7+b4Svy7ZWVCcNZJNx3y52w==",
"version": "52.0.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-52.0.2.tgz",
"integrity": "sha512-fYrnc7OpRifxxKjH78Y9/D/EouQDYD3G++bpR1Y+A+fy+CMzKZAdGIiHTIxCd2U10hb2y1NxN5TJt9aupq1vmw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@es-joy/jsdoccomment": "~0.52.0",
"are-docs-informative": "^0.0.2",
@@ -4068,6 +4195,23 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
@@ -4092,6 +4236,13 @@
"node": ">=10.13.0"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -4340,6 +4491,23 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
@@ -4598,6 +4766,41 @@
"node": ">=12.20.0"
}
},
"node_modules/fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fs-extra/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/fs-mkdirp-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-2.0.1.tgz",
@@ -4949,13 +5152,14 @@
}
},
"node_modules/google-closure-compiler": {
"version": "20250625.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20250625.0.0.tgz",
"integrity": "sha512-FQ6yKCRYwo4493Rq6lZrxpmWuJGZuuSruCdtArptkoThadzw4TM0YvQJvwRYnQDUpjj6/x7G14l2n/+8G39AIA==",
"version": "20250709.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20250709.0.0.tgz",
"integrity": "sha512-FUdjG7vri7Pi/iswJj1bFcE3cYOcGLnez2nKaEK8qSailRFQlnp8j9vuT60EOU8FLzckEPI0Sf882Q7vJPilFg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"chalk": "5.x",
"google-closure-compiler-java": "^20250625.0.0",
"google-closure-compiler-java": "^20250709.0.0",
"minimist": "1.x",
"vinyl": "3.x",
"vinyl-sourcemaps-apply": "^0.2.0"
@@ -4967,67 +5171,72 @@
"node": ">=18"
},
"optionalDependencies": {
"google-closure-compiler-linux": "^20250625.0.0",
"google-closure-compiler-linux-arm64": "^20250625.0.0",
"google-closure-compiler-macos": "^20250625.0.0",
"google-closure-compiler-windows": "^20250625.0.0"
"google-closure-compiler-linux": "^20250709.0.0",
"google-closure-compiler-linux-arm64": "^20250709.0.0",
"google-closure-compiler-macos": "^20250709.0.0",
"google-closure-compiler-windows": "^20250709.0.0"
}
},
"node_modules/google-closure-compiler-java": {
"version": "20250625.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20250625.0.0.tgz",
"integrity": "sha512-T916Kvb7JYaIiH9spiJXVKeualLV7PO/KXOJzMhLrW4M6etfvr3s2cTqlhUk+BrxvgxqWBWFbMDRUZbVGPnBaw==",
"dev": true
"version": "20250709.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20250709.0.0.tgz",
"integrity": "sha512-gyriPJ8nYxYVa5wqeMJZsOdFoDDcHSmGHG9VNYjQrcdIOWyxW9Ggcb2gtrI/MEa54CLoRbzUJ12ELO1mzePMlQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/google-closure-compiler-linux": {
"version": "20250625.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20250625.0.0.tgz",
"integrity": "sha512-2cOYLfG7RF49FnGG+yBGlEndE0es8D7+YIGgF8KnGIkxrfiZhOTyQftFx4z48TZ1Be/1JtM2eNXbD2fuR9nJdA==",
"version": "20250709.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20250709.0.0.tgz",
"integrity": "sha512-kpl9W+696vnGzpa/ewfwpsRR3t42g3CDQ5hFjQAitxtZpnejU7ik94+O8D+56049zS2O85LdWRDCbckvzEXw+w==",
"cpu": [
"x32",
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/google-closure-compiler-linux-arm64": {
"version": "20250625.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20250625.0.0.tgz",
"integrity": "sha512-2vKY8UpL03CFe+k1qFma/HnUZnTM3V3K5ukxmk/Xwt3D7CTwn/039zA3AjxsGW5vLp4guVyLtqbS711KeGpLNA==",
"version": "20250709.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20250709.0.0.tgz",
"integrity": "sha512-3mLAD9JpAM0StUb2VTOw4L/rIxksTO7lOfuI0+OyexQfLIRLM8M9jeUgrOAPbmgDsyYZ8Q3pHX2qcnURexZsrw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/google-closure-compiler-macos": {
"version": "20250625.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20250625.0.0.tgz",
"integrity": "sha512-/S3d5/oKKw2pEu42Bn+fnoKR0cAjlhOQP1IM0D1aDqNS+jMUXo4bV7RSVB+NSVL65XxIVQOqbnkD5Cfoe8lbrw==",
"version": "20250709.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20250709.0.0.tgz",
"integrity": "sha512-2/MXSVgM+HmnzwbyWdfY2ZVjKgK8LFtCKhsQQhsSV/f2jnrHcuG9+RkzLrzQsO1zPpHaLcXAkizf4AUpCfuzBA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/google-closure-compiler-windows": {
"version": "20250625.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20250625.0.0.tgz",
"integrity": "sha512-YBNRFTSuWXDJad1pJ1SPjPFpgImrQr7XeW1D9YrPCv1T5cfM8vy01jFkZIDuUha38kHsPvk7kG3rkYYrJpD8+Q==",
"version": "20250709.0.0",
"resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20250709.0.0.tgz",
"integrity": "sha512-ZnmgRzx0qIVQu0zw7ZTJQz3tMFVhwzeODZfXRnYDLeNkJA7IBaWsNHTALA7pUcgPM+YDDr4ihQOexMc0u4s7LQ==",
"cpu": [
"x32",
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
@@ -5328,10 +5537,11 @@
"dev": true
},
"node_modules/gulp-rename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/gulp-rename/-/gulp-rename-2.0.0.tgz",
"integrity": "sha512-97Vba4KBzbYmR5VBs9mWmK+HwIf5mj+/zioxfZhOKeXtx5ZjBk57KFlePf5nxq9QsTtFl0ejnHE3zTC9MHXqyQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/gulp-rename/-/gulp-rename-2.1.0.tgz",
"integrity": "sha512-dGuzuH8jQGqCMqC544IEPhs5+O2l+IkdoSZsgd4kY97M1CxQeI3qrmweQBIrxLBbjbe/8uEWK8HHcNBc3OCy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
@@ -6192,9 +6402,9 @@
"dev": true
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
@@ -6480,6 +6690,14 @@
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
"dev": true
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9201,6 +9419,16 @@
"node": ">= 10.13.0"
}
},
"node_modules/validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/value-or-function": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz",
@@ -9752,6 +9980,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"bin": {
"z-schema": "bin/z-schema"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"commander": "^9.4.1"
}
},
"node_modules/zip-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",

View File

@@ -100,24 +100,25 @@
},
"license": "Apache-2.0",
"devDependencies": {
"@blockly/block-test": "^7.0.1",
"@blockly/dev-tools": "^9.0.0",
"@blockly/theme-modern": "^6.0.3",
"@blockly/block-test": "^7.0.2",
"@blockly/dev-tools": "^9.0.2",
"@blockly/theme-modern": "^7.0.1",
"@hyperjump/browser": "^1.1.4",
"@hyperjump/json-schema": "^1.5.0",
"@microsoft/api-documenter": "^7.22.4",
"@microsoft/api-documenter": "7.22.4",
"@microsoft/api-extractor": "^7.29.5",
"ajv": "^8.17.1",
"async-done": "^2.0.0",
"chai": "^5.1.1",
"concurrently": "^9.0.1",
"eslint": "^9.15.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-jsdoc": "^51.3.1",
"eslint-plugin-jsdoc": "^52.0.2",
"eslint-plugin-prettier": "^5.2.1",
"glob": "^11.0.1",
"globals": "^16.0.0",
"google-closure-compiler": "^20250625.0.0",
"google-closure-compiler": "^20250709.0.0",
"gulp": "^5.0.0",
"gulp-concat": "^2.6.1",
"gulp-gzip": "^1.4.2",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -201,6 +201,35 @@ suite('Blocks', function () {
assertUnpluggedHealFailed(blocks);
});
test('Disconnect top of stack with immovable sibling', function () {
this.blocks.B.setMovable(false);
this.blocks.A.unplug(true);
assert.equal(this.blocks.A.nextConnection.targetBlock(), this.blocks.B);
assert.isNull(this.blocks.B.nextConnection.targetBlock());
assert.isNull(this.blocks.C.previousConnection.targetBlock());
});
test('Heal with immovable sibling mid-stack', function () {
const blockD = this.workspace.newBlock('stack_block', 'd');
this.blocks.C.nextConnection.connect(blockD.previousConnection);
this.blocks.C.setMovable(false);
this.blocks.B.unplug(true);
assert.equal(this.blocks.A.nextConnection.targetBlock(), blockD);
assert.equal(this.blocks.B.nextConnection.targetBlock(), this.blocks.C);
assert.isNull(this.blocks.C.nextConnection.targetBlock());
});
test('Heal with immovable sibling and shadow sibling mid-stack', function () {
const blockD = this.workspace.newBlock('stack_block', 'd');
const blockE = this.workspace.newBlock('stack_block', 'e');
this.blocks.C.nextConnection.connect(blockD.previousConnection);
blockD.nextConnection.connect(blockE.previousConnection);
this.blocks.C.setMovable(false);
blockD.setShadow(true);
this.blocks.B.unplug(true);
assert.equal(this.blocks.A.nextConnection.targetBlock(), blockE);
assert.equal(this.blocks.B.nextConnection.targetBlock(), this.blocks.C);
assert.equal(this.blocks.C.nextConnection.targetBlock(), blockD);
assert.isNull(blockD.nextConnection.targetBlock());
});
test('Child is shadow', function () {
const blocks = this.blocks;
blocks.C.setShadow(true);

View File

@@ -61,6 +61,31 @@ suite('Clipboard', function () {
);
});
test('pasting blocks includes next blocks if requested', function () {
const block = Blockly.serialization.blocks.append(
{
'type': 'controls_if',
'id': 'blockId',
'next': {
'block': {
'type': 'controls_if',
'id': 'blockId2',
},
},
},
this.workspace,
);
assert.equal(this.workspace.getBlocksByType('controls_if').length, 2);
// Both blocks should be copied
const data = block.toCopyData(true);
this.clock.runAll();
Blockly.clipboard.paste(data, this.workspace);
this.clock.runAll();
// After pasting, we should have gone from 2 to 4 blocks.
assert.equal(this.workspace.getBlocksByType('controls_if').length, 4);
});
test('copied from a mutator pastes them into the mutator', async function () {
const block = Blockly.serialization.blocks.append(
{

View File

@@ -183,6 +183,57 @@ suite('Toolbox', function () {
});
});
suite('focus management', function () {
setup(function () {
this.toolbox = getInjectedToolbox();
});
teardown(function () {
this.toolbox.dispose();
});
test('Losing focus hides autoclosing flyout', function () {
// Focus the toolbox and select a category to open the flyout.
const target = this.toolbox.HtmlDiv.querySelector(
'.blocklyToolboxCategory',
);
Blockly.getFocusManager().focusNode(this.toolbox);
target.dispatchEvent(
new PointerEvent('pointerdown', {
target,
bubbles: true,
}),
);
assert.isTrue(this.toolbox.getFlyout().isVisible());
// Focus the workspace to trigger the toolbox to close the flyout.
Blockly.getFocusManager().focusNode(this.toolbox.getWorkspace());
assert.isFalse(this.toolbox.getFlyout().isVisible());
});
test('Losing focus does not hide non-autoclosing flyout', function () {
// Make the toolbox's flyout non-autoclosing.
this.toolbox.getFlyout().setAutoClose(false);
// Focus the toolbox and select a category to open the flyout.
const target = this.toolbox.HtmlDiv.querySelector(
'.blocklyToolboxCategory',
);
Blockly.getFocusManager().focusNode(this.toolbox);
target.dispatchEvent(
new PointerEvent('pointerdown', {
target,
bubbles: true,
}),
);
assert.isTrue(this.toolbox.getFlyout().isVisible());
// Focus the workspace; this should *not* trigger the toolbox to close the
// flyout, which should remain visible.
Blockly.getFocusManager().focusNode(this.toolbox.getWorkspace());
assert.isTrue(this.toolbox.getFlyout().isVisible());
});
});
suite('onClick_', function () {
setup(function () {
this.toolbox = getInjectedToolbox();

View File

@@ -505,5 +505,26 @@ suite('Variable Map', function () {
});
});
});
suite('variable type change events', function () {
test('are fired when a variable has its type changed', function () {
const variable = this.variableMap.createVariable(
'name1',
'type1',
'id1',
);
this.variableMap.changeVariableType(variable, 'type2');
assertEventFired(
this.eventSpy,
Blockly.Events.VarTypeChange,
{
oldType: 'type1',
newType: 'type2',
varId: 'id1',
},
this.workspace.id,
);
});
});
});
});