diff --git a/.github/workflows/keyboard_plugin_test.yml b/.github/workflows/keyboard_plugin_test.yml new file mode 100644 index 00000000000..753d31dda1e --- /dev/null +++ b/.github/workflows/keyboard_plugin_test.yml @@ -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 diff --git a/core/block.ts b/core/block.ts index 43bc6bbc5ed..9f7c11d4faf 100644 --- a/core/block.ts +++ b/core/block.ts @@ -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() diff --git a/core/block_svg.ts b/core/block_svg.ts index 8ea26e354ef..49b4a1ee6f6 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -299,8 +299,19 @@ export class BlockSvg } const oldXY = this.getRelativeToSurfaceXY(); + const focusedNode = getFocusManager().getFocusedNode(); + const restoreFocus = this.getSvgRoot().contains( + focusedNode?.getFocusableElement() ?? null, + ); if (newParent) { (newParent as BlockSvg).getSvgRoot().appendChild(svgRoot); + // appendChild() clears focus state, so re-focus the previously focused + // node in case it was this block and would otherwise lose its focus. Once + // Element.moveBefore() has better browser support, it should be used + // instead. + if (restoreFocus && focusedNode) { + getFocusManager().focusNode(focusedNode); + } } else if (oldParent) { // If we are losing a parent, we want to move our DOM element to the // root of the workspace. Try to insert it before any top-level @@ -319,6 +330,13 @@ export class BlockSvg canvas.insertBefore(svgRoot, draggingBlockElement); } else { canvas.appendChild(svgRoot); + // appendChild() clears focus state, so re-focus the previously focused + // node in case it was this block and would otherwise lose its focus. Once + // Element.moveBefore() has better browser support, it should be used + // instead. + if (restoreFocus && focusedNode) { + getFocusManager().focusNode(focusedNode); + } } this.translate(oldXY.x, oldXY.y); } @@ -849,10 +867,30 @@ export class BlockSvg Tooltip.dispose(); ContextMenu.hide(); - // If this block was focused, focus its parent or workspace instead. + // If this block (or a descendant) was focused, focus its parent or + // workspace instead. const focusManager = getFocusManager(); - if (focusManager.getFocusedNode() === this) { - const parent = this.getParent(); + if ( + this.getSvgRoot().contains( + focusManager.getFocusedNode()?.getFocusableElement() ?? null, + ) + ) { + let parent: BlockSvg | undefined | null = this.getParent(); + if (!parent) { + // In some cases, blocks are disconnected from their parents before + // being deleted. Attempt to infer if there was a parent by checking + // for a connection within a radius of 0. Even if this wasn't a parent, + // it must be adjacent to this block and so is as good an option as any + // to focus after deleting. + const connection = this.outputConnection ?? this.previousConnection; + if (connection) { + const targetConnection = connection.closest( + 0, + new Coordinate(0, 0), + ).connection; + parent = targetConnection?.getSourceBlock(); + } + } if (parent) { focusManager.focusNode(parent); } else { @@ -1721,6 +1759,11 @@ export class BlockSvg this.dragStrategy = dragStrategy; } + /** Returns whether this block is copyable or not. */ + isCopyable(): boolean { + return this.isOwnDeletable() && this.isOwnMovable(); + } + /** Returns whether this block is movable or not. */ override isMovable(): boolean { return this.dragStrategy.isMovable(); diff --git a/core/bubbles/mini_workspace_bubble.ts b/core/bubbles/mini_workspace_bubble.ts index f6ea609361b..194cb41f35d 100644 --- a/core/bubbles/mini_workspace_bubble.ts +++ b/core/bubbles/mini_workspace_bubble.ts @@ -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(); diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts index 6281ad7584e..7479c06cfc5 100644 --- a/core/bubbles/textinput_bubble.ts +++ b/core/bubbles/textinput_bubble.ts @@ -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); } diff --git a/core/clipboard.ts b/core/clipboard.ts index 5fa654d630c..c7b22dfc7a8 100644 --- a/core/clipboard.ts +++ b/core/clipboard.ts @@ -9,6 +9,7 @@ import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js'; import * as registry from './clipboard/registry.js'; import type {ICopyData, ICopyable} from './interfaces/i_copyable.js'; +import {isSelectable} from './interfaces/i_selectable.js'; import * as globalRegistry from './registry.js'; import {Coordinate} from './utils/coordinate.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -18,18 +19,119 @@ let stashedCopyData: ICopyData | null = null; let stashedWorkspace: WorkspaceSvg | null = null; +let stashedCoordinates: Coordinate | undefined = undefined; + /** - * Private version of copy for stubbing in tests. + * Copy a copyable item, and record its data and the workspace it was + * copied from. + * + * This function does not perform any checks to ensure the copy + * should be allowed, e.g. to ensure the block is deletable. Such + * checks should be done before calling this function. + * + * Note that if the copyable item is not an `ISelectable` or its + * `workspace` property is not a `WorkspaceSvg`, the copy will be + * successful, but there will be no saved workspace data. This will + * impact the ability to paste the data unless you explictily pass + * a workspace into the paste method. + * + * @param toCopy item to copy. + * @param location location to save as a potential paste location. + * @returns the copied data if copy was successful, otherwise null. */ -function copyInternal(toCopy: ICopyable): T | null { +export function copy( + toCopy: ICopyable, + location?: Coordinate, +): T | null { const data = toCopy.toCopyData(); stashedCopyData = data; - stashedWorkspace = (toCopy as any).workspace ?? null; + if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) { + stashedWorkspace = toCopy.workspace; + } else { + stashedWorkspace = null; + } + + stashedCoordinates = location; return data; } /** - * Paste a pasteable element into the workspace. + * Gets the copy data for the last item copied. This is useful if you + * are implementing custom copy/paste behavior. If you want the default + * behavior, just use the copy and paste methods directly. + * + * @returns copy data for the last item copied, or null if none set. + */ +export function getLastCopiedData() { + return stashedCopyData; +} + +/** + * Sets the last copied item. You should call this method if you implement + * custom copy behavior, so that other callers are working with the correct + * data. This method is called automatically if you use the built-in copy + * method. + * + * @param copyData copy data for the last item copied. + */ +export function setLastCopiedData(copyData: ICopyData) { + stashedCopyData = copyData; +} + +/** + * Gets the workspace that was last copied from. This is useful if you + * are implementing custom copy/paste behavior and want to paste on the + * same workspace that was copied from. If you want the default behavior, + * just use the copy and paste methods directly. + * + * @returns workspace that was last copied from, or null if none set. + */ +export function getLastCopiedWorkspace() { + return stashedWorkspace; +} + +/** + * Sets the workspace that was last copied from. You should call this method + * if you implement custom copy behavior, so that other callers are working + * with the correct data. This method is called automatically if you use the + * built-in copy method. + * + * @param workspace workspace that was last copied from. + */ +export function setLastCopiedWorkspace(workspace: WorkspaceSvg) { + stashedWorkspace = workspace; +} + +/** + * Gets the location that was last copied from. This is useful if you + * are implementing custom copy/paste behavior. If you want the + * default behavior, just use the copy and paste methods directly. + * + * @returns last saved location, or null if none set. + */ +export function getLastCopiedLocation() { + return stashedCoordinates; +} + +/** + * Sets the location that was last copied from. You should call this method + * if you implement custom copy behavior, so that other callers are working + * with the correct data. This method is called automatically if you use the + * built-in copy method. + * + * @param location last saved location, which can be used to paste at. + */ +export function setLastCopiedLocation(location: Coordinate) { + stashedCoordinates = location; +} + +/** + * Paste a pasteable element into the given workspace. + * + * This function does not perform any checks to ensure the paste + * is allowed, e.g. that the workspace is rendered or the block + * is pasteable. Such checks should be done before calling this + * function. * * @param copyData The data to paste into the workspace. * @param workspace The workspace to paste the data into. @@ -43,7 +145,7 @@ export function paste( ): ICopyable | null; /** - * Pastes the last copied ICopyable into the workspace. + * Pastes the last copied ICopyable into the last copied-from workspace. * * @returns the pasted thing if the paste was successful, null otherwise. */ @@ -65,7 +167,7 @@ export function paste( ): ICopyable | null { if (!copyData || !workspace) { if (!stashedCopyData || !stashedWorkspace) return null; - return pasteFromData(stashedCopyData, stashedWorkspace); + return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates); } return pasteFromData(copyData, workspace, coordinate); } @@ -85,31 +187,11 @@ function pasteFromData( ): ICopyable | null { workspace = workspace.isMutator ? workspace - : (workspace.getRootWorkspace() ?? workspace); + : // Use the parent workspace if it exists (e.g. for pasting into flyouts) + (workspace.options.parentWorkspace ?? workspace); return (globalRegistry .getObject(globalRegistry.Type.PASTER, copyData.paster, false) ?.paste(copyData, workspace, coordinate) ?? null) as ICopyable | null; } -/** - * Private version of duplicate for stubbing in tests. - */ -function duplicateInternal< - U extends ICopyData, - T extends ICopyable & IHasWorkspace, ->(toDuplicate: T): T | null { - const data = toDuplicate.toCopyData(); - if (!data) return null; - return paste(data, toDuplicate.workspace) as T; -} - -interface IHasWorkspace { - workspace: WorkspaceSvg; -} - -export const TEST_ONLY = { - duplicateInternal, - copyInternal, -}; - export {BlockCopyData, BlockPaster, registry}; diff --git a/core/comments.ts b/core/comments.ts index ee85919873a..179ab4a33d0 100644 --- a/core/comments.ts +++ b/core/comments.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js'; +export {CommentBarButton} from './comments/comment_bar_button.js'; +export {CommentEditor} from './comments/comment_editor.js'; export {CommentView} from './comments/comment_view.js'; +export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js'; export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; export {WorkspaceComment} from './comments/workspace_comment.js'; diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts new file mode 100644 index 00000000000..b0738d70705 --- /dev/null +++ b/core/comments/collapse_comment_bar_button.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentBarButton} from './comment_bar_button.js'; + +/** + * Magic string appended to the comment ID to create a unique ID for this button. + */ +export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER = + '_collapse_bar_button'; + +/** + * Button that toggles the collapsed state of a comment. + */ +export class CollapseCommentBarButton extends CommentBarButton { + /** + * Opaque ID used to unbind event handlers during disposal. + */ + private readonly bindId: browserEvents.Data; + + /** + * SVG image displayed on this button. + */ + protected override readonly icon: SVGImageElement; + + /** + * Creates a new CollapseCommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is displayed on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) { + super(id, workspace, container); + + this.icon = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyFoldoutIcon', + 'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`, + 'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`, + }, + this.container, + ); + this.bindId = browserEvents.conditionalBind( + this.icon, + 'pointerdown', + this, + this.performAction.bind(this), + ); + } + + /** + * Disposes of this button. + */ + dispose() { + browserEvents.unbind(this.bindId); + } + + /** + * Adjusts the positioning of this button within its container. + */ + override reposition() { + const margin = this.getMargin(); + this.icon.setAttribute('y', `${margin}`); + this.icon.setAttribute('x', `${margin}`); + } + + /** + * Toggles the collapsed state of the parent comment. + * + * @param e The event that triggered this action. + */ + override performAction(e?: Event) { + touch.clearTouchIdentifier(); + + const comment = this.getParentComment(); + comment.view.bringToFront(); + if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + comment.setCollapsed(!comment.isCollapsed()); + this.workspace.hideChaff(); + + e?.stopPropagation(); + } +} diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts new file mode 100644 index 00000000000..d78a7fd86a1 --- /dev/null +++ b/core/comments/comment_bar_button.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {Rect} from '../utils/rect.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js'; + +/** + * Button displayed on a comment's top bar. + */ +export abstract class CommentBarButton implements IFocusableNode { + /** + * SVG image displayed on this button. + */ + protected abstract readonly icon: SVGImageElement; + + /** + * Creates a new CommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) {} + + /** + * Returns whether or not this button is currently visible. + */ + isVisible(): boolean { + return this.icon.checkVisibility(); + } + + /** + * Returns the parent comment of this comment bar button. + */ + getParentComment(): RenderedWorkspaceComment { + const comment = this.workspace.getCommentById(this.id); + if (!comment) { + throw new Error( + `Comment bar button ${this.id} has no corresponding comment`, + ); + } + + return comment; + } + + /** Adjusts the position of this button within its parent container. */ + abstract reposition(): void; + + /** Perform the action this button should take when it is acted on. */ + abstract performAction(e?: Event): void; + + /** + * Returns the dimensions of this button in workspace coordinates. + * + * @param includeMargin True to include the margin when calculating the size. + * @returns The size of this button. + */ + getSize(includeMargin = false): Rect { + const bounds = this.icon.getBBox(); + const rect = Rect.from(bounds); + if (includeMargin) { + const margin = this.getMargin(); + rect.left -= margin; + rect.top -= margin; + rect.bottom += margin; + rect.right += margin; + } + return rect; + } + + /** Returns the margin in workspace coordinates surrounding this button. */ + getMargin(): number { + return (this.container.getBBox().height - this.icon.getBBox().height) / 2; + } + + /** Returns a DOM element representing this button that can receive focus. */ + getFocusableElement() { + return this.icon; + } + + /** Returns the workspace this button is a child of. */ + getFocusableTree() { + return this.workspace; + } + + /** Called when this button's focusable DOM element gains focus. */ + onNodeFocus() {} + + /** Called when this button's focusable DOM element loses focus. */ + onNodeBlur() {} + + /** Returns whether this button can be focused. True if it is visible. */ + canBeFocused() { + return this.isVisible(); + } +} diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts new file mode 100644 index 00000000000..69dadd884f5 --- /dev/null +++ b/core/comments/comment_editor.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import {getFocusManager} from '../focus_manager.js'; +import {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * String added to the ID of a workspace comment to identify + * the focusable node for the comment editor. + */ +export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_'; + +/** The part of a comment that can be typed into. */ +export class CommentEditor implements IFocusableNode { + id?: string; + /** The foreignObject containing the HTML text area. */ + private foreignObject: SVGForeignObjectElement; + + /** The text area where the user can type. */ + private textArea: HTMLTextAreaElement; + + /** Listeners for changes to text. */ + private textChangeListeners: Array< + (oldText: string, newText: string) => void + > = []; + + /** The current text of the comment. Updates on text area change. */ + private text: string = ''; + + constructor( + public workspace: WorkspaceSvg, + commentId?: string, + private onFinishEditing?: () => void, + ) { + this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, { + 'class': 'blocklyCommentForeignObject', + }); + const body = document.createElementNS(dom.HTML_NS, 'body'); + body.setAttribute('xmlns', dom.HTML_NS); + body.className = 'blocklyMinimalBody'; + this.textArea = document.createElementNS( + dom.HTML_NS, + 'textarea', + ) as HTMLTextAreaElement; + this.textArea.setAttribute('tabindex', '-1'); + dom.addClass(this.textArea, 'blocklyCommentText'); + dom.addClass(this.textArea, 'blocklyTextarea'); + dom.addClass(this.textArea, 'blocklyText'); + body.appendChild(this.textArea); + this.foreignObject.appendChild(body); + + if (commentId) { + this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER; + this.textArea.setAttribute('id', this.id); + } + + // Register browser event listeners for the user typing in the textarea. + browserEvents.conditionalBind( + this.textArea, + 'change', + this, + this.onTextChange, + ); + + // Register listener for pointerdown to focus the textarea. + browserEvents.conditionalBind( + this.textArea, + 'pointerdown', + this, + (e: PointerEvent) => { + // don't allow this event to bubble up + // and steal focus away from the editor/comment. + e.stopPropagation(); + getFocusManager().focusNode(this); + touch.clearTouchIdentifier(); + }, + ); + + // Register listener for keydown events that would finish editing. + browserEvents.conditionalBind( + this.textArea, + 'keydown', + this, + this.handleKeyDown, + ); + } + + /** Gets the dom structure for this comment editor. */ + getDom(): SVGForeignObjectElement { + return this.foreignObject; + } + + /** Gets the current text of the comment. */ + getText(): string { + return this.text; + } + + /** Sets the current text of the comment and fires change listeners. */ + setText(text: string) { + this.textArea.value = text; + this.onTextChange(); + } + + /** + * Triggers listeners when the text of the comment changes, either + * programmatically or manually by the user. + */ + private onTextChange() { + const oldText = this.text; + this.text = this.textArea.value; + // Loop through listeners backwards in case they remove themselves. + for (let i = this.textChangeListeners.length - 1; i >= 0; i--) { + this.textChangeListeners[i](oldText, this.text); + } + } + + /** + * Do something when the user indicates they've finished editing. + * + * @param e Keyboard event. + */ + private handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { + if (this.onFinishEditing) this.onFinishEditing(); + e.stopPropagation(); + } + } + + /** Registers a callback that listens for text changes. */ + addTextChangeListener(listener: (oldText: string, newText: string) => void) { + this.textChangeListeners.push(listener); + } + + /** Removes the given listener from the list of text change listeners. */ + removeTextChangeListener(listener: () => void) { + this.textChangeListeners.splice( + this.textChangeListeners.indexOf(listener), + 1, + ); + } + + /** Sets the placeholder text displayed for an empty comment. */ + setPlaceholderText(text: string) { + this.textArea.placeholder = text; + } + + /** Sets whether the textarea is editable. If not, the textarea will be readonly. */ + setEditable(isEditable: boolean) { + if (isEditable) { + this.textArea.removeAttribute('readonly'); + } else { + this.textArea.setAttribute('readonly', 'true'); + } + } + + /** Update the size of the comment editor element. */ + updateSize(size: Size, topBarSize: Size) { + this.foreignObject.setAttribute( + 'height', + `${size.height - topBarSize.height}`, + ); + this.foreignObject.setAttribute('width', `${size.width}`); + this.foreignObject.setAttribute('y', `${topBarSize.height}`); + if (this.workspace.RTL) { + this.foreignObject.setAttribute('x', `${-size.width}`); + } + } + + getFocusableElement(): HTMLElement | SVGElement { + return this.textArea; + } + getFocusableTree(): IFocusableTree { + return this.workspace; + } + onNodeFocus(): void {} + onNodeBlur(): void {} + canBeFocused(): boolean { + if (this.id) return true; + return false; + } +} diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 26623d40f74..936d746508f 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -6,6 +6,7 @@ import * as browserEvents from '../browser_events.js'; import * as css from '../css.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import * as layers from '../layers.js'; import * as touch from '../touch.js'; @@ -15,13 +16,17 @@ import * as drag from '../utils/drag.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; import {WorkspaceSvg} from '../workspace_svg.js'; +import {CollapseCommentBarButton} from './collapse_comment_bar_button.js'; +import {CommentBarButton} from './comment_bar_button.js'; +import {CommentEditor} from './comment_editor.js'; +import {DeleteCommentBarButton} from './delete_comment_bar_button.js'; export class CommentView implements IRenderedElement { /** The root group element of the comment view. */ private svgRoot: SVGGElement; /** - * The svg rect element that we use to create a hightlight around the comment. + * The SVG rect element that we use to create a highlight around the comment. */ private highlightRect: SVGRectElement; @@ -31,11 +36,11 @@ export class CommentView implements IRenderedElement { /** The rect background for the top bar. */ private topBarBackground: SVGRectElement; - /** The delete icon that goes in the top bar. */ - private deleteIcon: SVGImageElement; + /** The delete button that goes in the top bar. */ + private deleteButton: DeleteCommentBarButton; - /** The foldout icon that goes in the top bar. */ - private foldoutIcon: SVGImageElement; + /** The foldout button that goes in the top bar. */ + private foldoutButton: CollapseCommentBarButton; /** The text element that goes in the top bar. */ private textPreview: SVGTextElement; @@ -46,11 +51,8 @@ export class CommentView implements IRenderedElement { /** The resize handle element. */ private resizeHandle: SVGImageElement; - /** The foreignObject containing the HTML text area. */ - private foreignObject: SVGForeignObjectElement; - - /** The text area where the user can type. */ - private textArea: HTMLTextAreaElement; + /** The part of the comment view that contains the textarea to edit the comment. */ + private commentEditor: CommentEditor; /** The current size of the comment in workspace units. */ private size: Size; @@ -64,14 +66,6 @@ export class CommentView implements IRenderedElement { /** The current location of the comment in workspace coordinates. */ private location: Coordinate = new Coordinate(0, 0); - /** The current text of the comment. Updates on text area change. */ - private text: string = ''; - - /** Listeners for changes to text. */ - private textChangeListeners: Array< - (oldText: string, newText: string) => void - > = []; - /** Listeners for changes to size. */ private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> = []; @@ -106,7 +100,10 @@ export class CommentView implements IRenderedElement { /** The default size of newly created comments. */ static defaultCommentSize = new Size(120, 100); - constructor(readonly workspace: WorkspaceSvg) { + constructor( + readonly workspace: WorkspaceSvg, + private commentId: string, + ) { this.svgRoot = dom.createSvgElement(Svg.G, { 'class': 'blocklyComment blocklyEditable blocklyDraggable', }); @@ -116,14 +113,13 @@ export class CommentView implements IRenderedElement { ({ topBarGroup: this.topBarGroup, topBarBackground: this.topBarBackground, - deleteIcon: this.deleteIcon, - foldoutIcon: this.foldoutIcon, + deleteButton: this.deleteButton, + foldoutButton: this.foldoutButton, textPreview: this.textPreview, textPreviewNode: this.textPreviewNode, - } = this.createTopBar(this.svgRoot, workspace)); + } = this.createTopBar(this.svgRoot)); - ({foreignObject: this.foreignObject, textArea: this.textArea} = - this.createTextArea(this.svgRoot)); + this.commentEditor = this.createTextArea(); this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace); @@ -154,14 +150,11 @@ export class CommentView implements IRenderedElement { * Creates the top bar and the elements visually within it. * Registers event listeners. */ - private createTopBar( - svgRoot: SVGGElement, - workspace: WorkspaceSvg, - ): { + private createTopBar(svgRoot: SVGGElement): { topBarGroup: SVGGElement; topBarBackground: SVGRectElement; - deleteIcon: SVGImageElement; - foldoutIcon: SVGImageElement; + deleteButton: DeleteCommentBarButton; + foldoutButton: CollapseCommentBarButton; textPreview: SVGTextElement; textPreviewNode: Text; } { @@ -179,22 +172,14 @@ export class CommentView implements IRenderedElement { }, topBarGroup, ); - // TODO: Before merging, does this mean to override an individual image, - // folks need to replace the whole media folder? - const deleteIcon = dom.createSvgElement( - Svg.IMAGE, - { - 'class': 'blocklyDeleteIcon', - 'href': `${workspace.options.pathToMedia}delete-icon.svg`, - }, + const deleteButton = new DeleteCommentBarButton( + this.commentId, + this.workspace, topBarGroup, ); - const foldoutIcon = dom.createSvgElement( - Svg.IMAGE, - { - 'class': 'blocklyFoldoutIcon', - 'href': `${workspace.options.pathToMedia}foldout-icon.svg`, - }, + const foldoutButton = new CollapseCommentBarButton( + this.commentId, + this.workspace, topBarGroup, ); const textPreview = dom.createSvgElement( @@ -207,27 +192,11 @@ export class CommentView implements IRenderedElement { const textPreviewNode = document.createTextNode(''); textPreview.appendChild(textPreviewNode); - // TODO(toychest): Triggering this on pointerdown means that we can't start - // drags on the foldout icon. We need to open up the gesture system - // to fix this. - browserEvents.conditionalBind( - foldoutIcon, - 'pointerdown', - this, - this.onFoldoutDown, - ); - browserEvents.conditionalBind( - deleteIcon, - 'pointerdown', - this, - this.onDeleteDown, - ); - return { topBarGroup, topBarBackground, - deleteIcon, - foldoutIcon, + deleteButton, + foldoutButton, textPreview, textPreviewNode, }; @@ -236,33 +205,32 @@ export class CommentView implements IRenderedElement { /** * Creates the text area where users can type. Registers event listeners. */ - private createTextArea(svgRoot: SVGGElement): { - foreignObject: SVGForeignObjectElement; - textArea: HTMLTextAreaElement; - } { - const foreignObject = dom.createSvgElement( - Svg.FOREIGNOBJECT, - { - 'class': 'blocklyCommentForeignObject', - }, - svgRoot, + private createTextArea() { + // When the user is done editing comment, focus the entire comment. + const onFinishEditing = () => this.svgRoot.focus(); + const commentEditor = new CommentEditor( + this.workspace, + this.commentId, + onFinishEditing, ); - const body = document.createElementNS(dom.HTML_NS, 'body'); - body.setAttribute('xmlns', dom.HTML_NS); - body.className = 'blocklyMinimalBody'; - const textArea = document.createElementNS( - dom.HTML_NS, - 'textarea', - ) as HTMLTextAreaElement; - dom.addClass(textArea, 'blocklyCommentText'); - dom.addClass(textArea, 'blocklyTextarea'); - dom.addClass(textArea, 'blocklyText'); - body.appendChild(textArea); - foreignObject.appendChild(body); - browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange); + this.svgRoot.appendChild(commentEditor.getDom()); + + commentEditor.addTextChangeListener((oldText, newText) => { + this.updateTextPreview(newText); + // Update size in case our minimum size increased. + this.setSize(this.size); + }); + + return commentEditor; + } - return {foreignObject, textArea}; + /** + * + * @returns The FocusableNode representing the editor portion of this comment. + */ + getEditorFocusableNode(): IFocusableNode { + return this.commentEditor; } /** Creates the DOM elements for the comment resize handle. */ @@ -308,15 +276,10 @@ export class CommentView implements IRenderedElement { */ setSizeWithoutFiringEvents(size: Size) { const topBarSize = this.topBarBackground.getBBox(); - const deleteSize = this.deleteIcon.getBBox(); - const foldoutSize = this.foldoutIcon.getBBox(); const textPreviewSize = this.textPreview.getBBox(); const resizeSize = this.resizeHandle.getBBox(); - size = Size.max( - size, - this.calcMinSize(topBarSize, foldoutSize, deleteSize), - ); + size = Size.max(size, this.calcMinSize(topBarSize)); this.size = size; this.svgRoot.setAttribute('height', `${size.height}`); @@ -324,16 +287,10 @@ export class CommentView implements IRenderedElement { this.updateHighlightRect(size); this.updateTopBarSize(size); - this.updateTextAreaSize(size, topBarSize); - this.updateDeleteIconPosition(size, topBarSize, deleteSize); - this.updateFoldoutIconPosition(topBarSize, foldoutSize); - this.updateTextPreviewSize( - size, - topBarSize, - textPreviewSize, - deleteSize, - resizeSize, - ); + this.commentEditor.updateSize(size, topBarSize); + this.deleteButton.reposition(); + this.foldoutButton.reposition(); + this.updateTextPreviewSize(size, topBarSize, textPreviewSize); this.updateResizeHandlePosition(size, resizeSize); } @@ -355,25 +312,18 @@ export class CommentView implements IRenderedElement { * * The minimum height is based on the height of the top bar. */ - private calcMinSize( - topBarSize: Size, - foldoutSize: Size, - deleteSize: Size, - ): Size { - this.updateTextPreview(this.textArea.value ?? ''); + private calcMinSize(topBarSize: Size): Size { + this.updateTextPreview(this.commentEditor.getText() ?? ''); const textPreviewWidth = dom.getTextWidth(this.textPreview); - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - let width = textPreviewWidth; - if (this.foldoutIcon.checkVisibility()) { - width += foldoutSize.width + foldoutMargin * 2; + if (this.foldoutButton.isVisible()) { + width += this.foldoutButton.getSize(true).getWidth(); } else if (textPreviewWidth) { width += 4; // Arbitrary margin before text. } - if (this.deleteIcon.checkVisibility()) { - width += deleteSize.width + deleteMargin * 2; + if (this.deleteButton.isVisible()) { + width += this.deleteButton.getSize(true).getWidth(); } else if (textPreviewWidth) { width += 4; // Arbitrary margin after text. } @@ -384,16 +334,6 @@ export class CommentView implements IRenderedElement { return new Size(width, height); } - /** Calculates the margin that should exist around the delete icon. */ - private calcDeleteMargin(topBarSize: Size, deleteSize: Size) { - return (topBarSize.height - deleteSize.height) / 2; - } - - /** Calculates the margin that should exist around the foldout icon. */ - private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) { - return (topBarSize.height - foldoutSize.height) / 2; - } - /** Updates the size of the highlight rect to reflect the new size. */ private updateHighlightRect(size: Size) { this.highlightRect.setAttribute('height', `${size.height}`); @@ -408,44 +348,6 @@ export class CommentView implements IRenderedElement { this.topBarBackground.setAttribute('width', `${size.width}`); } - /** Updates the size of the text area elements to reflect the new size. */ - private updateTextAreaSize(size: Size, topBarSize: Size) { - this.foreignObject.setAttribute( - 'height', - `${size.height - topBarSize.height}`, - ); - this.foreignObject.setAttribute('width', `${size.width}`); - this.foreignObject.setAttribute('y', `${topBarSize.height}`); - if (this.workspace.RTL) { - this.foreignObject.setAttribute('x', `${-size.width}`); - } - } - - /** - * Updates the position of the delete icon elements to reflect the new size. - */ - private updateDeleteIconPosition( - size: Size, - topBarSize: Size, - deleteSize: Size, - ) { - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - this.deleteIcon.setAttribute('y', `${deleteMargin}`); - this.deleteIcon.setAttribute( - 'x', - `${size.width - deleteSize.width - deleteMargin}`, - ); - } - - /** - * Updates the position of the foldout icon elements to reflect the new size. - */ - private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) { - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); - this.foldoutIcon.setAttribute('y', `${foldoutMargin}`); - this.foldoutIcon.setAttribute('x', `${foldoutMargin}`); - } - /** * Updates the size and position of the text preview elements to reflect the new size. */ @@ -453,25 +355,14 @@ export class CommentView implements IRenderedElement { size: Size, topBarSize: Size, textPreviewSize: Size, - deleteSize: Size, - foldoutSize: Size, ) { const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2; - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); + const foldoutSize = this.foldoutButton.getSize(true); + const deleteSize = this.deleteButton.getSize(true); const textPreviewWidth = - size.width - - foldoutSize.width - - foldoutMargin * 2 - - deleteSize.width - - deleteMargin * 2; - this.textPreview.setAttribute( - 'x', - `${ - foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1) - }`, - ); + size.width - foldoutSize.getWidth() - deleteSize.getWidth(); + this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`); this.textPreview.setAttribute( 'y', `${textPreviewMargin + textPreviewSize.height / 2}`, @@ -622,25 +513,6 @@ export class CommentView implements IRenderedElement { ); } - /** - * Toggles the collapsedness of the block when we receive a pointer down - * event on the foldout icon. - */ - private onFoldoutDown(e: PointerEvent) { - touch.clearTouchIdentifier(); - this.bringToFront(); - if (browserEvents.isRightButton(e)) { - e.stopPropagation(); - return; - } - - this.setCollapsed(!this.collapsed); - - this.workspace.hideChaff(); - - e.stopPropagation(); - } - /** Returns true if the comment is currently editable. */ isEditable(): boolean { return this.editable; @@ -652,12 +524,11 @@ export class CommentView implements IRenderedElement { if (this.editable) { dom.addClass(this.svgRoot, 'blocklyEditable'); dom.removeClass(this.svgRoot, 'blocklyReadonly'); - this.textArea.removeAttribute('readonly'); } else { dom.removeClass(this.svgRoot, 'blocklyEditable'); dom.addClass(this.svgRoot, 'blocklyReadonly'); - this.textArea.setAttribute('readonly', 'true'); } + this.commentEditor.setEditable(editable); } /** Returns the current location of the comment in workspace coordinates. */ @@ -678,49 +549,29 @@ export class CommentView implements IRenderedElement { ); } - /** Retursn the current text of the comment. */ + /** Returns the current text of the comment. */ getText() { - return this.text; + return this.commentEditor.getText(); } /** Sets the current text of the comment. */ setText(text: string) { - this.textArea.value = text; - this.onTextChange(); + this.commentEditor.setText(text); } /** Sets the placeholder text displayed for an empty comment. */ setPlaceholderText(text: string) { - this.textArea.placeholder = text; + this.commentEditor.setPlaceholderText(text); } - /** Registers a callback that listens for text changes. */ + /** Registers a callback that listens for text changes on the comment editor. */ addTextChangeListener(listener: (oldText: string, newText: string) => void) { - this.textChangeListeners.push(listener); + this.commentEditor.addTextChangeListener(listener); } - /** Removes the given listener from the list of text change listeners. */ + /** Removes the given listener from the comment editor. */ removeTextChangeListener(listener: () => void) { - this.textChangeListeners.splice( - this.textChangeListeners.indexOf(listener), - 1, - ); - } - - /** - * Triggers listeners when the text of the comment changes, either - * programmatically or manually by the user. - */ - private onTextChange() { - const oldText = this.text; - this.text = this.textArea.value; - this.updateTextPreview(this.text); - // Update size in case our minimum size increased. - this.setSize(this.size); - // Loop through listeners backwards in case they remove themselves. - for (let i = this.textChangeListeners.length - 1; i >= 0; i--) { - this.textChangeListeners[i](oldText, this.text); - } + this.commentEditor.removeTextChangeListener(listener); } /** Updates the preview text element to reflect the given text. */ @@ -734,7 +585,7 @@ export class CommentView implements IRenderedElement { } /** Brings the workspace comment to the front of its layer. */ - private bringToFront() { + bringToFront() { const parent = this.svgRoot.parentNode; const childNodes = parent!.childNodes; // Avoid moving the comment if it's already at the bottom. @@ -761,6 +612,8 @@ export class CommentView implements IRenderedElement { /** Disposes of this comment view. */ dispose() { this.disposing = true; + this.foldoutButton.dispose(); + this.deleteButton.dispose(); dom.removeNode(this.svgRoot); // Loop through listeners backwards in case they remove themselves. for (let i = this.disposeListeners.length - 1; i >= 0; i--) { @@ -791,6 +644,13 @@ export class CommentView implements IRenderedElement { removeDisposeListener(listener: () => void) { this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1); } + + /** + * @internal + */ + getCommentBarButtons(): CommentBarButton[] { + return [this.foldoutButton, this.deleteButton]; + } } css.register(` @@ -884,6 +744,11 @@ css.register(` fill: none; } +.blocklyCommentText.blocklyActiveFocus { + border-color: #fc3; + border-width: 2px; +} + .blocklySelected .blocklyCommentHighlight { stroke: #fc3; stroke-width: 3px; diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts new file mode 100644 index 00000000000..0b7dcd0ea27 --- /dev/null +++ b/core/comments/delete_comment_bar_button.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import {getFocusManager} from '../focus_manager.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentBarButton} from './comment_bar_button.js'; + +/** + * Magic string appended to the comment ID to create a unique ID for this button. + */ +export const COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER = '_delete_bar_button'; + +/** + * Button that deletes a comment. + */ +export class DeleteCommentBarButton extends CommentBarButton { + /** + * Opaque ID used to unbind event handlers during disposal. + */ + private readonly bindId: browserEvents.Data; + + /** + * SVG image displayed on this button. + */ + protected override readonly icon: SVGImageElement; + + /** + * Creates a new DeleteCommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is shown on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) { + super(id, workspace, container); + + this.icon = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyDeleteIcon', + 'href': `${this.workspace.options.pathToMedia}delete-icon.svg`, + 'id': `${this.id}${COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER}`, + }, + container, + ); + this.bindId = browserEvents.conditionalBind( + this.icon, + 'pointerdown', + this, + this.performAction.bind(this), + ); + } + + /** + * Disposes of this button. + */ + dispose() { + browserEvents.unbind(this.bindId); + } + + /** + * Adjusts the positioning of this button within its container. + */ + override reposition() { + const margin = this.getMargin(); + // Reset to 0 so that our position doesn't force the parent container to + // grow. + this.icon.setAttribute('x', `0`); + const containerSize = this.container.getBBox(); + this.icon.setAttribute('y', `${margin}`); + this.icon.setAttribute( + 'x', + `${containerSize.width - this.getSize(true).getWidth()}`, + ); + } + + /** + * Deletes parent comment. + * + * @param e The event that triggered this action. + */ + override performAction(e?: Event) { + touch.clearTouchIdentifier(); + if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + this.getParentComment().dispose(); + e?.stopPropagation(); + getFocusManager().focusNode(this.workspace); + } +} diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 3a3d57a441d..3457e611a7e 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -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(); diff --git a/core/comments/workspace_comment.ts b/core/comments/workspace_comment.ts index 190efd64dd1..b5dc3023cfe 100644 --- a/core/comments/workspace_comment.ts +++ b/core/comments/workspace_comment.ts @@ -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 ); } diff --git a/core/common.ts b/core/common.ts index a4b198ae490..7f23779ec93 100644 --- a/core/common.ts +++ b/core/common.ts @@ -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}; diff --git a/core/connection.ts b/core/connection.ts index fbd094dba69..a79b7b9b143 100644 --- a/core/connection.ts +++ b/core/connection.ts @@ -83,6 +83,12 @@ export class Connection { public type: number, ) { this.sourceBlock_ = source; + if (source.id.includes('_connection')) { + throw new Error( + `Connection ID indicator is contained in block ID. This will cause ` + + `problems with focus: ${source.id}.`, + ); + } this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`; } diff --git a/core/field.ts b/core/field.ts index c4b6514785e..fdcb2d693b9 100644 --- a/core/field.ts +++ b/core/field.ts @@ -265,6 +265,12 @@ export abstract class Field throw Error('Field already bound to a block'); } this.sourceBlock_ = block; + if (block.id.includes('_field')) { + throw new Error( + `Field ID indicator is contained in block ID. This may cause ` + + `problems with focus: ${block.id}.`, + ); + } this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } diff --git a/core/field_input.ts b/core/field_input.ts index c7921d6f015..b685309183a 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -27,6 +27,7 @@ import { FieldValidator, UnattachedFieldError, } from './field.js'; +import {getFocusManager} from './focus_manager.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; @@ -83,8 +84,8 @@ export abstract class FieldInput extends Field< /** Key down event data. */ private onKeyDownWrapper: browserEvents.Data | null = null; - /** Key input event data. */ - private onKeyInputWrapper: browserEvents.Data | null = null; + /** Input element input event data. */ + private onInputWrapper: browserEvents.Data | null = null; /** * Whether the field should consider the whole parent block to be its click @@ -558,7 +559,7 @@ export abstract class FieldInput extends Field< this.onHtmlInputKeyDown_, ); // Resize after every input change. - this.onKeyInputWrapper = browserEvents.conditionalBind( + this.onInputWrapper = browserEvents.conditionalBind( htmlInput, 'input', this, @@ -572,9 +573,9 @@ export abstract class FieldInput extends Field< browserEvents.unbind(this.onKeyDownWrapper); this.onKeyDownWrapper = null; } - if (this.onKeyInputWrapper) { - browserEvents.unbind(this.onKeyInputWrapper); - this.onKeyInputWrapper = null; + if (this.onInputWrapper) { + browserEvents.unbind(this.onInputWrapper); + this.onInputWrapper = null; } } @@ -614,6 +615,14 @@ export abstract class FieldInput extends Field< if (target instanceof FieldInput) { WidgetDiv.hideIfOwner(this); dropDownDiv.hideWithoutAnimation(); + const targetSourceBlock = target.getSourceBlock(); + if ( + target.isFullBlockField() && + targetSourceBlock && + targetSourceBlock instanceof BlockSvg + ) { + getFocusManager().focusNode(targetSourceBlock); + } else getFocusManager().focusNode(target); target.showEditor(); } } @@ -622,7 +631,7 @@ export abstract class FieldInput extends Field< /** * Handle a change to the editor. * - * @param _e Keyboard event. + * @param _e InputEvent. */ private onHtmlInputChange(_e: Event) { // Intermediate value changes from user input are not confirmed until the diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 3d0a9347f85..02e0591070f 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -174,8 +174,15 @@ export class FocusManager { this.registeredTrees.push( new TreeRegistration(tree, rootShouldBeAutoTabbable), ); + const rootElement = tree.getRootFocusableNode().getFocusableElement(); + if (!rootElement.id || rootElement.id === 'null') { + throw Error( + `Attempting to register a tree with a root element that has an ` + + `invalid ID: ${tree}.`, + ); + } if (rootShouldBeAutoTabbable) { - tree.getRootFocusableNode().getFocusableElement().tabIndex = 0; + rootElement.tabIndex = 0; } } @@ -344,13 +351,22 @@ export class FocusManager { throw Error(`Attempted to focus unregistered node: ${focusableNode}.`); } + const focusableNodeElement = focusableNode.getFocusableElement(); + if (!focusableNodeElement.id || focusableNodeElement.id === 'null') { + // Warn that the ID is invalid, but continue execution since an invalid ID + // will result in an unmatched (null) node. Since a request to focus + // something was initiated, the code below will attempt to find the next + // best thing to focus, instead. + console.warn('Trying to focus a node that has an invalid ID.'); + } + // Safety check for ensuring focusNode() doesn't get called for a node that // isn't actually hooked up to its parent tree correctly. This usually // happens when calls to focusNode() interleave with asynchronous clean-up // operations (which can happen due to ephemeral focus and in other cases). // Fall back to a reasonable default since there's no valid node to focus. const matchedNode = FocusableTreeTraverser.findFocusableNodeFor( - focusableNode.getFocusableElement(), + focusableNodeElement, nextTree, ); const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree); diff --git a/core/icons/mutator_icon.ts b/core/icons/mutator_icon.ts index 1842855fab5..9055a91ea8f 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -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; } diff --git a/core/interfaces/i_autohideable.ts b/core/interfaces/i_autohideable.ts index 41e761f57ca..1193023d21b 100644 --- a/core/interfaces/i_autohideable.ts +++ b/core/interfaces/i_autohideable.ts @@ -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'; } diff --git a/core/interfaces/i_comment_icon.ts b/core/interfaces/i_comment_icon.ts index 05f86f40ff9..1ab5bead447 100644 --- a/core/interfaces/i_comment_icon.ts +++ b/core/interfaces/i_comment_icon.ts @@ -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 ); } diff --git a/core/interfaces/i_copyable.ts b/core/interfaces/i_copyable.ts index b653bd20a10..8d1853967d4 100644 --- a/core/interfaces/i_copyable.ts +++ b/core/interfaces/i_copyable.ts @@ -15,6 +15,14 @@ export interface ICopyable 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 { - return obj.toCopyData !== undefined; + return obj && typeof obj.toCopyData === 'function'; } diff --git a/core/interfaces/i_deletable.ts b/core/interfaces/i_deletable.ts index 0467709409a..156e43ddc50 100644 --- a/core/interfaces/i_deletable.ts +++ b/core/interfaces/i_deletable.ts @@ -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' ); } diff --git a/core/interfaces/i_draggable.ts b/core/interfaces/i_draggable.ts index cb723e7b88b..9130381163f 100644 --- a/core/interfaces/i_draggable.ts +++ b/core/interfaces/i_draggable.ts @@ -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' ); } diff --git a/core/interfaces/i_focusable_node.ts b/core/interfaces/i_focusable_node.ts index 00557168afa..24833328d7f 100644 --- a/core/interfaces/i_focusable_node.ts +++ b/core/interfaces/i_focusable_node.ts @@ -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' ); } diff --git a/core/interfaces/i_focusable_tree.ts b/core/interfaces/i_focusable_tree.ts index f4f25f7f518..c33189fcdf0 100644 --- a/core/interfaces/i_focusable_tree.ts +++ b/core/interfaces/i_focusable_tree.ts @@ -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' ); } diff --git a/core/interfaces/i_has_bubble.ts b/core/interfaces/i_has_bubble.ts index 85c6f099031..0c2e257a440 100644 --- a/core/interfaces/i_has_bubble.ts +++ b/core/interfaces/i_has_bubble.ts @@ -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' ); } diff --git a/core/interfaces/i_icon.ts b/core/interfaces/i_icon.ts index 74489dc5e09..06f416424ef 100644 --- a/core/interfaces/i_icon.ts +++ b/core/interfaces/i_icon.ts @@ -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' ); } diff --git a/core/interfaces/i_legacy_procedure_blocks.ts b/core/interfaces/i_legacy_procedure_blocks.ts index d74eaec220a..c723a5ed77c 100644 --- a/core/interfaces/i_legacy_procedure_blocks.ts +++ b/core/interfaces/i_legacy_procedure_blocks.ts @@ -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' ); } diff --git a/core/interfaces/i_observable.ts b/core/interfaces/i_observable.ts index 96a2a0bc4e8..8db0c237874 100644 --- a/core/interfaces/i_observable.ts +++ b/core/interfaces/i_observable.ts @@ -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' + ); } diff --git a/core/interfaces/i_paster.ts b/core/interfaces/i_paster.ts index 321ff118f70..128913a26b1 100644 --- a/core/interfaces/i_paster.ts +++ b/core/interfaces/i_paster.ts @@ -21,5 +21,5 @@ export interface IPaster> { export function isPaster( obj: any, ): obj is IPaster> { - return obj.paste !== undefined; + return obj && typeof obj.paste === 'function'; } diff --git a/core/interfaces/i_procedure_block.ts b/core/interfaces/i_procedure_block.ts index f8538052749..3a6dc4847b9 100644 --- a/core/interfaces/i_procedure_block.ts +++ b/core/interfaces/i_procedure_block.ts @@ -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' ); } diff --git a/core/interfaces/i_rendered_element.ts b/core/interfaces/i_rendered_element.ts index fe9460c7f6a..2f82487e9be 100644 --- a/core/interfaces/i_rendered_element.ts +++ b/core/interfaces/i_rendered_element.ts @@ -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'; } diff --git a/core/interfaces/i_selectable.ts b/core/interfaces/i_selectable.ts index 639972e45cb..5374f50cd3a 100644 --- a/core/interfaces/i_selectable.ts +++ b/core/interfaces/i_selectable.ts @@ -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' ); } diff --git a/core/interfaces/i_serializable.ts b/core/interfaces/i_serializable.ts index 380a277095d..99e597da37a 100644 --- a/core/interfaces/i_serializable.ts +++ b/core/interfaces/i_serializable.ts @@ -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' + ); } diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 570b06fe392..9f56b538455 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -8,8 +8,11 @@ import {BlockSvg} from '../block_svg.js'; import {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; import type {Icon} from '../icons/icon.js'; +import type {IBoundedElement} from '../interfaces/i_bounded_element.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import type {ISelectable} from '../interfaces/i_selectable.js'; import {RenderedConnection} from '../rendered_connection.js'; import {WorkspaceSvg} from '../workspace_svg.js'; @@ -24,7 +27,7 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @returns The first field or input of the given block, if any. */ getFirstChild(current: BlockSvg): IFocusableNode | null { - const candidates = getBlockNavigationCandidates(current); + const candidates = getBlockNavigationCandidates(current, true); return candidates[0]; } @@ -58,6 +61,8 @@ export class BlockNavigationPolicy implements INavigationPolicy { return current.nextConnection?.targetBlock(); } else if (current.outputConnection?.targetBlock()) { return navigateBlock(current, 1); + } else if (current.getSurroundParent()) { + return navigateBlock(current.getTopStackBlock(), 1); } else if (this.getParent(current) instanceof WorkspaceSvg) { return navigateStacks(current, 1); } @@ -111,14 +116,27 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @param block The block to retrieve the navigable children of. * @returns A list of navigable/focusable children of the given block. */ -function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] { +function getBlockNavigationCandidates( + block: BlockSvg, + forward: boolean, +): IFocusableNode[] { const candidates: IFocusableNode[] = block.getIcons(); for (const input of block.inputList) { if (!input.isVisible()) continue; candidates.push(...input.fieldRow); if (input.connection?.targetBlock()) { - candidates.push(input.connection.targetBlock() as BlockSvg); + const connectedBlock = input.connection.targetBlock() as BlockSvg; + if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) { + const lastStackBlock = connectedBlock + .lastConnectionInStack(false) + ?.getSourceBlock(); + if (lastStackBlock) { + candidates.push(lastStackBlock); + } + } else { + candidates.push(connectedBlock); + } } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { candidates.push(input.connection as RenderedConnection); } @@ -128,21 +146,25 @@ function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] { } /** - * Returns the next/previous stack relative to the given block's stack. + * Returns the next/previous stack relative to the given element's stack. * - * @param current The block whose stack will be navigated relative to. + * @param current The element whose stack will be navigated relative to. * @param delta The difference in index to navigate; positive values navigate * to the nth next stack, while negative values navigate to the nth previous * stack. - * @returns The first block in the stack offset by `delta` relative to the - * current block's stack, or the last block in the stack offset by `delta` - * relative to the current block's stack when navigating backwards. + * @returns The first element in the stack offset by `delta` relative to the + * current element's stack, or the last element in the stack offset by + * `delta` relative to the current element's stack when navigating backwards. */ -export function navigateStacks(current: BlockSvg, delta: number) { - const stacks = current.workspace.getTopBlocks(true); - const currentIndex = stacks.indexOf(current.getRootBlock()); +export function navigateStacks(current: ISelectable, delta: number) { + const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg) + .getTopBoundedElements(true) + .filter((element: IBoundedElement) => isFocusableNode(element)); + const currentIndex = stacks.indexOf( + current instanceof BlockSvg ? current.getRootBlock() : current, + ); const targetIndex = currentIndex + delta; - let result: BlockSvg | null = null; + let result: IFocusableNode | null = null; if (targetIndex >= 0 && targetIndex < stacks.length) { result = stacks[targetIndex]; } else if (targetIndex < 0) { @@ -151,9 +173,9 @@ export function navigateStacks(current: BlockSvg, delta: number) { result = stacks[0]; } - // When navigating to a previous stack, our previous sibling is the last + // When navigating to a previous block stack, our previous sibling is the last // block in it. - if (delta < 0 && result) { + if (delta < 0 && result instanceof BlockSvg) { return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; } @@ -174,11 +196,11 @@ export function navigateBlock( ): IFocusableNode | null { const block = current instanceof BlockSvg - ? current.outputConnection.targetBlock() + ? (current.outputConnection?.targetBlock() ?? current.getSurroundParent()) : current.getSourceBlock(); if (!(block instanceof BlockSvg)) return null; - const candidates = getBlockNavigationCandidates(block); + const candidates = getBlockNavigationCandidates(block, delta > 0); const currentIndex = candidates.indexOf(current); if (currentIndex === -1) return null; diff --git a/core/keyboard_nav/comment_bar_button_navigation_policy.ts b/core/keyboard_nav/comment_bar_button_navigation_policy.ts new file mode 100644 index 00000000000..f676f465582 --- /dev/null +++ b/core/keyboard_nav/comment_bar_button_navigation_policy.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentBarButton} from '../comments/comment_bar_button.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a CommentBarButton. + */ +export class CommentBarButtonNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given CommentBarButton. + * + * @param _current The CommentBarButton to return the first child of. + * @returns Null. + */ + getFirstChild(_current: CommentBarButton): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given CommentBarButton. + * + * @param current The CommentBarButton to return the parent of. + * @returns The parent comment of the given CommentBarButton. + */ + getParent(current: CommentBarButton): IFocusableNode | null { + return current.getParentComment(); + } + + /** + * Returns the next peer node of the given CommentBarButton. + * + * @param current The CommentBarButton to find the following element of. + * @returns The next CommentBarButton, if any. + */ + getNextSibling(current: CommentBarButton): IFocusableNode | null { + const children = current.getParentComment().view.getCommentBarButtons(); + const currentIndex = children.indexOf(current); + if (currentIndex >= 0 && currentIndex + 1 < children.length) { + return children[currentIndex + 1]; + } + return null; + } + + /** + * Returns the previous peer node of the given CommentBarButton. + * + * @param current The CommentBarButton to find the preceding element of. + * @returns The CommentBarButton's previous CommentBarButton, if any. + */ + getPreviousSibling(current: CommentBarButton): IFocusableNode | null { + const children = current.getParentComment().view.getCommentBarButtons(); + const currentIndex = children.indexOf(current); + if (currentIndex > 0) { + return children[currentIndex - 1]; + } + return null; + } + + /** + * Returns whether or not the given CommentBarButton can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given CommentBarButton can be focused. + */ + isNavigable(current: CommentBarButton): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an CommentBarButton. + */ + isApplicable(current: any): current is CommentBarButton { + return current instanceof CommentBarButton; + } +} diff --git a/core/keyboard_nav/icon_navigation_policy.ts b/core/keyboard_nav/icon_navigation_policy.ts index 96908cbbdf8..70631ce81af 100644 --- a/core/keyboard_nav/icon_navigation_policy.ts +++ b/core/keyboard_nav/icon_navigation_policy.ts @@ -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 { /** * 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; } diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 85c0f414a07..a301c3b37e0 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -14,10 +14,10 @@ */ import {BlockSvg} from '../block_svg.js'; +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; import {Field} from '../field.js'; import {getFocusManager} from '../focus_manager.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; import {WorkspaceSvg} from '../workspace_svg.js'; import {Marker} from './marker.js'; @@ -39,11 +39,11 @@ export class LineCursor extends Marker { } /** - * Moves the cursor to the next previous connection, next connection or block - * in the pre order traversal. Finds the next node in the pre order traversal. + * Moves the cursor to the next block or workspace comment in the pre-order + * traversal. * - * @returns The next node, or null if the current node is - * not set or there is no next value. + * @returns The next node, or null if the current node is not set or there is + * no next value. */ next(): IFocusableNode | null { const curNode = this.getCurNode(); @@ -54,8 +54,9 @@ export class LineCursor extends Marker { curNode, (candidate: IFocusableNode | null) => { return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment ); }, true, @@ -88,11 +89,11 @@ export class LineCursor extends Marker { return newNode; } /** - * Moves the cursor to the previous next connection or previous connection in - * the pre order traversal. + * Moves the cursor to the previous block or workspace comment in the + * pre-order traversal. * - * @returns The previous node, or null if the current node - * is not set or there is no previous value. + * @returns The previous node, or null if the current node is not set or there + * is no previous value. */ prev(): IFocusableNode | null { const curNode = this.getCurNode(); @@ -103,8 +104,9 @@ export class LineCursor extends Marker { curNode, (candidate: IFocusableNode | null) => { return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment ); }, true, @@ -374,17 +376,8 @@ export class LineCursor extends Marker { * * @returns The current field, connection, or block the cursor is on. */ - override getCurNode(): IFocusableNode | null { - // Ensure the current node matches what's currently focused. - const focused = getFocusManager().getFocusedNode(); - const block = this.getSourceBlockFromNode(focused); - if (block && block.workspace === this.workspace) { - // If the current focused node corresponds to a block then ensure that it - // belongs to the correct workspace for this cursor. - this.setCurNode(focused); - } - - return super.getCurNode(); + getCurNode(): IFocusableNode | null { + return getFocusManager().getFocusedNode(); } /** @@ -395,12 +388,8 @@ export class LineCursor extends Marker { * * @param newNode The new location of the cursor. */ - override setCurNode(newNode: IFocusableNode | null) { - super.setCurNode(newNode); - - if (isFocusableNode(newNode)) { - getFocusManager().focusNode(newNode); - } + setCurNode(newNode: IFocusableNode) { + getFocusManager().focusNode(newNode); // Try to scroll cursor into view. if (newNode instanceof BlockSvg) { @@ -412,6 +401,8 @@ export class LineCursor extends Marker { block.workspace.scrollBoundsIntoView( block.getBoundingRectangleWithoutChildren(), ); + } else if (newNode instanceof RenderedWorkspaceComment) { + newNode.workspace.scrollBoundsIntoView(newNode.getBoundingRectangle()); } } diff --git a/core/keyboard_nav/workspace_comment_navigation_policy.ts b/core/keyboard_nav/workspace_comment_navigation_policy.ts new file mode 100644 index 00000000000..7fe70ceadef --- /dev/null +++ b/core/keyboard_nav/workspace_comment_navigation_policy.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {navigateStacks} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from an RenderedWorkspaceComment. + */ +export class WorkspaceCommentNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given workspace comment. + * + * @param current The workspace comment to return the first child of. + * @returns The first child button of the given comment. + */ + getFirstChild(current: RenderedWorkspaceComment): IFocusableNode | null { + return current.view.getCommentBarButtons()[0]; + } + + /** + * Returns the parent of the given workspace comment. + * + * @param current The workspace comment to return the parent of. + * @returns The parent workspace of the given comment. + */ + getParent(current: RenderedWorkspaceComment): IFocusableNode | null { + return current.workspace; + } + + /** + * Returns the next peer node of the given workspace comment. + * + * @param current The workspace comment to find the following element of. + * @returns The next workspace comment or block stack, if any. + */ + getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null { + return navigateStacks(current, 1); + } + + /** + * Returns the previous peer node of the given workspace comment. + * + * @param current The workspace comment to find the preceding element of. + * @returns The previous workspace comment or block stack, if any. + */ + getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null { + return navigateStacks(current, -1); + } + + /** + * Returns whether or not the given workspace comment can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given workspace comment can be focused. + */ + isNavigable(current: RenderedWorkspaceComment): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an RenderedWorkspaceComment. + */ + isApplicable(current: any): current is RenderedWorkspaceComment { + return current instanceof RenderedWorkspaceComment; + } +} diff --git a/core/keyboard_nav/workspace_navigation_policy.ts b/core/keyboard_nav/workspace_navigation_policy.ts index 12a7555b43f..b671f8fe739 100644 --- a/core/keyboard_nav/workspace_navigation_policy.ts +++ b/core/keyboard_nav/workspace_navigation_policy.ts @@ -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; } /** diff --git a/core/marker_manager.ts b/core/marker_manager.ts index 95a2d9b8bce..e94aa3e966a 100644 --- a/core/marker_manager.ts +++ b/core/marker_manager.ts @@ -11,7 +11,7 @@ */ // Former goog.module ID: Blockly.MarkerManager -import type {LineCursor} from './keyboard_nav/line_cursor.js'; +import {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Marker} from './keyboard_nav/marker.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -23,7 +23,7 @@ export class MarkerManager { static readonly LOCAL_MARKER = 'local_marker_1'; /** The cursor. */ - private cursor: LineCursor | null = null; + private cursor: LineCursor; /** The map of markers for the workspace. */ private markers = new Map(); @@ -32,7 +32,9 @@ export class MarkerManager { * @param workspace The workspace for the marker manager. * @internal */ - constructor(private readonly workspace: WorkspaceSvg) {} + constructor(private readonly workspace: WorkspaceSvg) { + this.cursor = new LineCursor(this.workspace); + } /** * Register the marker by adding it to the map of markers. @@ -72,7 +74,7 @@ export class MarkerManager { * * @returns The cursor for this workspace. */ - getCursor(): LineCursor | null { + getCursor(): LineCursor { return this.cursor; } @@ -109,9 +111,6 @@ export class MarkerManager { this.unregisterMarker(markerId); } this.markers.clear(); - if (this.cursor) { - this.cursor.dispose(); - this.cursor = null; - } + this.cursor.dispose(); } } diff --git a/core/navigator.ts b/core/navigator.ts index 92c921122dc..2f095f6f962 100644 --- a/core/navigator.ts +++ b/core/navigator.ts @@ -7,9 +7,11 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; +import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js'; import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js'; +import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js'; import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; type RuleList = INavigationPolicy[]; @@ -29,6 +31,8 @@ export class Navigator { new ConnectionNavigationPolicy(), new WorkspaceNavigationPolicy(), new IconNavigationPolicy(), + new WorkspaceCommentNavigationPolicy(), + new CommentBarButtonNavigationPolicy(), ]; /** @@ -64,9 +68,8 @@ export class Navigator { getFirstChild(current: IFocusableNode): IFocusableNode | null { const result = this.get(current)?.getFirstChild(current); if (!result) return null; - // If the child isn't navigable, don't traverse into it; check its peers. if (!this.get(result)?.isNavigable(result)) { - return this.getNextSibling(result); + return this.getFirstChild(result) || this.getNextSibling(result); } return result; } diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 161d5fceb13..f8c95500770 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -8,19 +8,12 @@ import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; +import {RenderedWorkspaceComment} from './comments.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; -import {Gesture} from './gesture.js'; -import { - ICopyable, - ICopyData, - isCopyable as isICopyable, -} from './interfaces/i_copyable.js'; -import { - IDeletable, - isDeletable as isIDeletable, -} from './interfaces/i_deletable.js'; -import {IDraggable, isDraggable} from './interfaces/i_draggable.js'; +import {isCopyable as isICopyable} from './interfaces/i_copyable.js'; +import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; +import {isDraggable} from './interfaces/i_draggable.js'; import {IFocusableNode} from './interfaces/i_focusable_node.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; import {Coordinate} from './utils/coordinate.js'; @@ -73,7 +66,7 @@ export function registerDelete() { focused != null && isIDeletable(focused) && focused.isDeletable() && - !Gesture.inProgress() && + !workspace.isDragging() && // Don't delete the block if a field editor is open !getFocusManager().ephemeralFocusTaken() ); @@ -99,75 +92,41 @@ export function registerDelete() { ShortcutRegistry.registry.register(deleteShortcut); } -let copyData: ICopyData | null = null; -let copyWorkspace: WorkspaceSvg | null = null; -let copyCoords: Coordinate | null = null; - /** * Determine if a focusable node can be copied. * - * Unfortunately the ICopyable interface doesn't include an isCopyable - * method, so we must use some other criteria to make the decision. - * Specifically, - * - * - It must be an ICopyable. - * - So that a pasted copy can be manipluated and/or disposed of, it - * must be both an IDraggable and an IDeletable. - * - Additionally, both .isOwnMovable() and .isOwnDeletable() must return - * true (i.e., the copy could be moved and deleted). - * - * TODO(#9098): Revise these criteria. The latter criteria prevents - * shadow blocks from being copied; additionally, there are likely to - * be other circumstances were it is desirable to allow movable / - * copyable copies of a currently-unmovable / -copyable block to be - * made. + * This will use the isCopyable method if the node implements it, otherwise + * it will fall back to checking if the node is deletable and draggable not + * considering the workspace's edit state. * * @param focused The focused object. */ -function isCopyable( - focused: IFocusableNode, -): focused is ICopyable & IDeletable & IDraggable { - if (!(focused instanceof BlockSvg)) return false; - return ( - isICopyable(focused) && - isIDeletable(focused) && - focused.isOwnDeletable() && - isDraggable(focused) && - focused.isOwnMovable() - ); +function isCopyable(focused: IFocusableNode): boolean { + if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused)) + return false; + if (focused.isCopyable) { + return focused.isCopyable(); + } else if ( + focused instanceof BlockSvg || + focused instanceof RenderedWorkspaceComment + ) { + return focused.isOwnDeletable() && focused.isOwnMovable(); + } + // This isn't a class Blockly knows about, so fall back to the stricter + // checks for deletable and movable. + return focused.isDeletable() && focused.isMovable(); } /** * Determine if a focusable node can be cut. * - * Unfortunately the ICopyable interface doesn't include an isCuttable - * method, so we must use some other criteria to make the decision. - * Specifically, - * - * - It must be an ICopyable. - * - So that a pasted copy can be manipluated and/or disposed of, it - * must be both an IDraggable and an IDeletable. - * - Additionally, both .isMovable() and .isDeletable() must return - * true (i.e., can currently be moved and deleted). This is the main - * difference with isCopyable. - * - * TODO(#9098): Revise these criteria. The latter criteria prevents - * shadow blocks from being copied; additionally, there are likely to - * be other circumstances were it is desirable to allow movable / - * copyable copies of a currently-unmovable / -copyable block to be - * made. + * This will check if the node can be both copied and deleted in its current + * workspace. * * @param focused The focused object. */ function isCuttable(focused: IFocusableNode): boolean { - if (!(focused instanceof BlockSvg)) return false; - return ( - isICopyable(focused) && - isIDeletable(focused) && - focused.isDeletable() && - isDraggable(focused) && - focused.isMovable() - ); + return isCopyable(focused) && isIDeletable(focused) && focused.isDeletable(); } /** @@ -185,7 +144,6 @@ export function registerCopy() { name: names.COPY, preconditionFn(workspace, scope) { const focused = scope.focusedNode; - if (!(focused instanceof BlockSvg)) return false; const targetWorkspace = workspace.isFlyout ? workspace.targetWorkspace @@ -193,7 +151,6 @@ export function registerCopy() { return ( !!focused && !!targetWorkspace && - !targetWorkspace.isReadOnly() && !targetWorkspace.isDragging() && !getFocusManager().ephemeralFocusTaken() && isCopyable(focused) @@ -205,26 +162,22 @@ export function registerCopy() { e.preventDefault(); const focused = scope.focusedNode; - if (!focused || !isCopyable(focused)) return false; - let targetWorkspace: WorkspaceSvg | null = - focused.workspace instanceof WorkspaceSvg - ? focused.workspace - : workspace; - targetWorkspace = targetWorkspace.isFlyout - ? targetWorkspace.targetWorkspace - : targetWorkspace; + if (!focused || !isICopyable(focused) || !isCopyable(focused)) + return false; + const targetWorkspace = workspace.isFlyout + ? workspace.targetWorkspace + : workspace; if (!targetWorkspace) return false; if (!focused.workspace.isFlyout) { targetWorkspace.hideChaff(); } - copyData = focused.toCopyData(); - copyWorkspace = targetWorkspace; - copyCoords = + + const copyCoords = isDraggable(focused) && focused.workspace == targetWorkspace ? focused.getRelativeToSurfaceXY() - : null; - return !!copyData; + : undefined; + return !!clipboard.copy(focused, copyCoords); }, keyCodes: [ctrlC, metaC], }; @@ -256,27 +209,20 @@ export function registerCut() { }, callback(workspace, e, shortcut, scope) { const focused = scope.focusedNode; + if (!focused || !isCuttable(focused) || !isICopyable(focused)) { + return false; + } + const copyCoords = isDraggable(focused) + ? focused.getRelativeToSurfaceXY() + : undefined; + const copyData = clipboard.copy(focused, copyCoords); if (focused instanceof BlockSvg) { - copyData = focused.toCopyData(); - copyWorkspace = workspace; - copyCoords = focused.getRelativeToSurfaceXY(); focused.checkAndDelete(); - return true; - } else if ( - isIDeletable(focused) && - focused.isDeletable() && - isICopyable(focused) - ) { - copyData = focused.toCopyData(); - copyWorkspace = workspace; - copyCoords = isDraggable(focused) - ? focused.getRelativeToSurfaceXY() - : null; + } else if (isIDeletable(focused)) { focused.dispose(); - return true; } - return false; + return !!copyData; }, keyCodes: [ctrlX, metaX], }; @@ -297,12 +243,19 @@ export function registerPaste() { const pasteShortcut: KeyboardShortcut = { name: names.PASTE, - preconditionFn(workspace) { + preconditionFn() { + // Regardless of the currently focused workspace, we will only + // paste into the last-copied-from workspace. + const workspace = clipboard.getLastCopiedWorkspace(); + // If we don't know where we copied from, we don't know where to paste. + // If the workspace isn't rendered (e.g. closed mutator workspace), + // we can't paste into it. + if (!workspace || !workspace.rendered) return false; const targetWorkspace = workspace.isFlyout ? workspace.targetWorkspace : workspace; return ( - !!copyData && + !!clipboard.getLastCopiedData() && !!targetWorkspace && !targetWorkspace.isReadOnly() && !targetWorkspace.isDragging() && @@ -310,7 +263,16 @@ export function registerPaste() { ); }, callback(workspace: WorkspaceSvg, e: Event) { - if (!copyData || !copyWorkspace) return false; + const copyData = clipboard.getLastCopiedData(); + if (!copyData) return false; + + const copyWorkspace = clipboard.getLastCopiedWorkspace(); + if (!copyWorkspace) return false; + + const targetWorkspace = copyWorkspace.isFlyout + ? copyWorkspace.targetWorkspace + : copyWorkspace; + if (!targetWorkspace || targetWorkspace.isReadOnly()) return false; if (e instanceof PointerEvent) { // The event that triggers a shortcut would conventionally be a KeyboardEvent. @@ -319,19 +281,20 @@ export function registerPaste() { // at the mouse coordinates where the menu was opened, and this PointerEvent // is where the menu was opened. const mouseCoords = svgMath.screenToWsCoordinates( - copyWorkspace, + targetWorkspace, new Coordinate(e.clientX, e.clientY), ); - return !!clipboard.paste(copyData, copyWorkspace, mouseCoords); + return !!clipboard.paste(copyData, targetWorkspace, mouseCoords); } + const copyCoords = clipboard.getLastCopiedLocation(); if (!copyCoords) { // If we don't have location data about the original copyable, let the // paster determine position. - return !!clipboard.paste(copyData, copyWorkspace); + return !!clipboard.paste(copyData, targetWorkspace); } - const {left, top, width, height} = copyWorkspace + const {left, top, width, height} = targetWorkspace .getMetricsManager() .getViewMetrics(true); const viewportRect = new Rect(top, top + height, left, left + width); @@ -339,12 +302,12 @@ export function registerPaste() { if (viewportRect.contains(copyCoords.x, copyCoords.y)) { // If the original copyable is inside the viewport, let the paster // determine position. - return !!clipboard.paste(copyData, copyWorkspace); + return !!clipboard.paste(copyData, targetWorkspace); } // Otherwise, paste in the middle of the viewport. const centerCoords = new Coordinate(left + width / 2, top + height / 2); - return !!clipboard.paste(copyData, copyWorkspace, centerCoords); + return !!clipboard.paste(copyData, targetWorkspace, centerCoords); }, keyCodes: [ctrlV, metaV], }; @@ -368,7 +331,7 @@ export function registerUndo() { preconditionFn(workspace) { return ( !workspace.isReadOnly() && - !Gesture.inProgress() && + !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() ); }, @@ -390,12 +353,12 @@ export function registerUndo() { */ export function registerRedo() { const ctrlShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.SHIFT, KeyCodes.CTRL, + KeyCodes.SHIFT, ]); const metaShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.SHIFT, KeyCodes.META, + KeyCodes.SHIFT, ]); // Ctrl-y is redo in Windows. Command-y is never valid on Macs. const ctrlY = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Y, [ @@ -406,7 +369,7 @@ export function registerRedo() { name: names.REDO, preconditionFn(workspace) { return ( - !Gesture.inProgress() && + !workspace.isDragging() && !workspace.isReadOnly() && !getFocusManager().ephemeralFocusTaken() ); diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 57e849ce264..4979fdfa40f 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -43,6 +43,7 @@ import type {KeyboardShortcut} from '../shortcut_registry.js'; import * as Touch from '../touch.js'; import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; +import * as idGenerator from '../utils/idgenerator.js'; import {Rect} from '../utils/rect.js'; import * as toolbox from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -185,6 +186,7 @@ export class Toolbox const svg = workspace.getParentSvg(); const container = this.createContainer_(); + container.id = idGenerator.getNextUniqueId(); this.contentsDiv_ = this.createContentsContainer_(); aria.setRole(this.contentsDiv_, aria.Role.TREE); @@ -1170,6 +1172,7 @@ Css.register(` /* Category tree in Toolbox. */ .blocklyToolbox { + box-sizing: border-box; user-select: none; -ms-user-select: none; -webkit-user-select: none; diff --git a/core/utils/focusable_tree_traverser.ts b/core/utils/focusable_tree_traverser.ts index 916437b6a73..aa4585b828d 100644 --- a/core/utils/focusable_tree_traverser.ts +++ b/core/utils/focusable_tree_traverser.ts @@ -79,8 +79,8 @@ export class FocusableTreeTraverser { * traversed but its nodes will never be returned here per the contract of * IFocusableTree.lookUpFocusableNode. * - * The provided element must have a non-null ID that conforms to the contract - * mentioned in IFocusableNode. + * The provided element must have a non-null, non-empty ID that conforms to + * the contract mentioned in IFocusableNode. * * @param element The HTML or SVG element being sought. * @param tree The tree under which the provided element may be a descendant. @@ -90,6 +90,10 @@ export class FocusableTreeTraverser { element: HTMLElement | SVGElement, tree: IFocusableTree, ): IFocusableNode | null { + // Note that the null check is due to Element.setAttribute() converting null + // to a string. + if (!element.id || element.id === 'null') return null; + // First, match against subtrees. const subTreeMatches = tree.getNestedTrees().map((tree) => { return FocusableTreeTraverser.findFocusableNodeFor(element, tree); diff --git a/core/utils/rect.ts b/core/utils/rect.ts index c7da2a6860b..5a6822633e1 100644 --- a/core/utils/rect.ts +++ b/core/utils/rect.ts @@ -32,6 +32,16 @@ export class Rect { public right: number, ) {} + /** + * Converts a DOM or SVG Rect to a Blockly Rect. + * + * @param rect The rectangle to convert. + * @returns A representation of the same rectangle as a Blockly Rect. + */ + static from(rect: DOMRect | SVGRect): Rect { + return new Rect(rect.y, rect.y + rect.height, rect.x, rect.x + rect.width); + } + /** * Creates a new copy of this rectangle. * @@ -51,6 +61,11 @@ export class Rect { return this.right - this.left; } + /** Returns the top left coordinate of this rectangle. */ + getOrigin(): Coordinate { + return new Coordinate(this.left, this.top); + } + /** * Tests whether this rectangle contains a x/y coordinate. * diff --git a/core/workspace.ts b/core/workspace.ts index f7b866447c4..5f205193912 100644 --- a/core/workspace.ts +++ b/core/workspace.ts @@ -21,6 +21,7 @@ import * as common from './common.js'; import type {ConnectionDB} from './connection_db.js'; import type {Abstract} from './events/events_abstract.js'; import * as eventUtils from './events/utils.js'; +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; import {IProcedureMap} from './interfaces/i_procedure_map.js'; import type {IVariableMap} from './interfaces/i_variable_map.js'; @@ -35,6 +36,7 @@ import * as arrayUtils from './utils/array.js'; import * as deprecation from './utils/deprecation.js'; import * as idGenerator from './utils/idgenerator.js'; import * as math from './utils/math.js'; +import {Rect} from './utils/rect.js'; import type * as toolbox from './utils/toolbox.js'; import {deleteVariable, getVariableUsesById} from './variables.js'; @@ -181,10 +183,31 @@ export class Workspace { a: Block | WorkspaceComment, b: Block | WorkspaceComment, ): number { + const wrap = (element: Block | WorkspaceComment) => { + return { + getBoundingRectangle: () => { + const xy = element.getRelativeToSurfaceXY(); + return new Rect(xy.y, xy.y, xy.x, xy.x); + }, + moveBy: () => {}, + }; + }; + return this.sortByOrigin(wrap(a), wrap(b)); + } + + /** + * Sorts bounded elements on the workspace by their relative position, top to + * bottom (with slight LTR or RTL bias). + * + * @param a The first element to sort. + * @param b The second elment to sort. + * @returns -1, 0 or 1 depending on the sort order. + */ + protected sortByOrigin(a: IBoundedElement, b: IBoundedElement): number { const offset = Math.sin(math.toRadians(Workspace.SCAN_ANGLE)) * (this.RTL ? -1 : 1); - const aXY = a.getRelativeToSurfaceXY(); - const bXY = b.getRelativeToSurfaceXY(); + const aXY = a.getBoundingRectangle().getOrigin(); + const bXY = b.getBoundingRectangle().getOrigin(); return aXY.y + offset * aXY.x - (bXY.y + offset * bXY.x); } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 9eb5ea545b8..d713f11cf43 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -22,6 +22,9 @@ import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; import type {BlocklyOptions} from './blockly_options.js'; import * as browserEvents from './browser_events.js'; +import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js'; +import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js'; +import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js'; import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; import {WorkspaceComment} from './comments/workspace_comment.js'; import * as common from './common.js'; @@ -41,6 +44,7 @@ import type {FlyoutButton} from './flyout_button.js'; import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; import {Grid} from './grid.js'; +import {MutatorIcon} from './icons/mutator_icon.js'; import {isAutoHideable} from './interfaces/i_autohideable.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; @@ -476,10 +480,7 @@ export class WorkspaceSvg * @internal */ getMarker(id: string): Marker | null { - if (this.markerManager) { - return this.markerManager.getMarker(id); - } - return null; + return this.markerManager.getMarker(id); } /** @@ -487,11 +488,8 @@ export class WorkspaceSvg * * @returns The cursor for the workspace. */ - getCursor(): LineCursor | null { - if (this.markerManager) { - return this.markerManager.getCursor(); - } - return null; + getCursor(): LineCursor { + return this.markerManager.getCursor(); } /** @@ -895,10 +893,7 @@ export class WorkspaceSvg } this.renderer.dispose(); - - if (this.markerManager) { - this.markerManager.dispose(); - } + this.markerManager.dispose(); super.dispose(); @@ -2264,8 +2259,8 @@ export class WorkspaceSvg * * @param comment comment to add. */ - override addTopComment(comment: WorkspaceComment) { - this.addTopBoundedElement(comment as RenderedWorkspaceComment); + override addTopComment(comment: RenderedWorkspaceComment) { + this.addTopBoundedElement(comment); super.addTopComment(comment); } @@ -2274,11 +2269,31 @@ export class WorkspaceSvg * * @param comment comment to remove. */ - override removeTopComment(comment: WorkspaceComment) { - this.removeTopBoundedElement(comment as RenderedWorkspaceComment); + override removeTopComment(comment: RenderedWorkspaceComment) { + this.removeTopBoundedElement(comment); super.removeTopComment(comment); } + /** + * Returns a list of comments on this workspace. + * + * @param ordered If true, sorts the comments based on their position. + * @returns A list of workspace comments. + */ + override getTopComments(ordered = false): RenderedWorkspaceComment[] { + return super.getTopComments(ordered) as RenderedWorkspaceComment[]; + } + + /** + * Returns the workspace comment with the given ID, if any. + * + * @param id The ID of the comment to retrieve. + * @returns The workspace comment with the given ID, or null. + */ + override getCommentById(id: string): RenderedWorkspaceComment | null { + return super.getCommentById(id) as RenderedWorkspaceComment | null; + } + override getRootWorkspace(): WorkspaceSvg | null { return super.getRootWorkspace() as WorkspaceSvg | null; } @@ -2306,8 +2321,15 @@ export class WorkspaceSvg * * @returns The top-level bounded elements. */ - getTopBoundedElements(): IBoundedElement[] { - return new Array().concat(this.topBoundedElements); + getTopBoundedElements(ordered = false): IBoundedElement[] { + const elements = new Array().concat( + this.topBoundedElements, + ); + if (ordered) { + elements.sort(this.sortByOrigin.bind(this)); + } + + return elements; } /** @@ -2680,7 +2702,7 @@ export class WorkspaceSvg /** See IFocusableNode.getFocusableTree. */ getFocusableTree(): IFocusableTree { - return this; + return (this.isMutator && this.options.parentWorkspace) || this; } /** See IFocusableNode.onNodeFocus. */ @@ -2710,7 +2732,42 @@ export class WorkspaceSvg /** See IFocusableTree.getNestedTrees. */ getNestedTrees(): Array { - return []; + const nestedWorkspaces = this.getAllBlocks() + .map((block) => block.getIcons()) + .flat() + .filter( + (icon): icon is MutatorIcon => + icon instanceof MutatorIcon && icon.bubbleIsVisible(), + ) + .map((icon) => icon.getBubble()?.getWorkspace()) + .filter((workspace) => !!workspace); + + const ownFlyout = this.getFlyout(true); + if (ownFlyout) { + nestedWorkspaces.push(ownFlyout.getWorkspace()); + } + + return nestedWorkspaces; + } + + /** + * Used for searching for a specific workspace comment. + * We can't use this.getWorkspaceCommentById because the workspace + * comment ids might not be globally unique, but the id assigned to + * the focusable element for the comment should be. + */ + private searchForWorkspaceComment( + id: string, + ): RenderedWorkspaceComment | undefined { + for (const comment of this.getTopComments()) { + if ( + comment instanceof RenderedWorkspaceComment && + comment.canBeFocused() && + comment.getFocusableElement().id === id + ) { + return comment; + } + } } /** See IFocusableTree.lookUpFocusableNode. */ @@ -2757,21 +2814,42 @@ export class WorkspaceSvg return null; } + // Search for a specific workspace comment or comment icon if the ID + // indicates the presence of one. + const commentIdSeparatorIndex = Math.max( + id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER), + id.indexOf(COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER), + id.indexOf(COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER), + ); + if (commentIdSeparatorIndex !== -1) { + const commentId = id.substring(0, commentIdSeparatorIndex); + const comment = this.searchForWorkspaceComment(commentId); + if (comment) { + if (id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER) > -1) { + return comment.getEditorFocusableNode(); + } else { + return ( + comment.view + .getCommentBarButtons() + .find((button) => button.getFocusableElement().id.includes(id)) ?? + null + ); + } + } + } + // Search for a specific block. + // Don't use `getBlockById` because the block ID is not guaranteed + // to be globally unique, but the ID on the focusable element is. const block = this.getAllBlocks(false).find( (block) => block.getFocusableElement().id === id, ); if (block) return block; // Search for a workspace comment (semi-expensive). - for (const comment of this.getTopComments()) { - if ( - comment instanceof RenderedWorkspaceComment && - comment.canBeFocused() && - comment.getFocusableElement().id === id - ) { - return comment; - } + const comment = this.searchForWorkspaceComment(id); + if (comment) { + return comment; } // Search for icons and bubbles (which requires an expensive getAllBlocks). diff --git a/eslint.config.mjs b/eslint.config.mjs index 68f25133fa5..f018e525d87 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -184,7 +184,7 @@ export default [ files: [ 'eslint.config.mjs', '.prettierrc.js', - 'gulpfile.js', + 'gulpfile.mjs', 'scripts/helpers.js', 'tests/mocha/.mocharc.js', 'tests/migration/validate-renamings.mjs', diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index d2ad650c64a..00000000000 --- a/gulpfile.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2018 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Gulp script to build Blockly for Node & NPM. - * Run this script by calling "npm install" in this directory. - */ -/* eslint-env node */ - -const gulp = require('gulp'); - -const buildTasks = require('./scripts/gulpfiles/build_tasks'); -const packageTasks = require('./scripts/gulpfiles/package_tasks'); -const gitTasks = require('./scripts/gulpfiles/git_tasks'); -const appengineTasks = require('./scripts/gulpfiles/appengine_tasks'); -const releaseTasks = require('./scripts/gulpfiles/release_tasks'); -const docsTasks = require('./scripts/gulpfiles/docs_tasks'); -const testTasks = require('./scripts/gulpfiles/test_tasks'); - -module.exports = { - // Default target if gulp invoked without specifying. - default: buildTasks.build, - - // Main sequence targets. They already invoke prerequisites. - langfiles: buildTasks.langfiles, // Build build/msg/*.js from msg/json/*. - tsc: buildTasks.tsc, - deps: buildTasks.deps, - minify: buildTasks.minify, - build: buildTasks.build, - package: packageTasks.package, - publish: releaseTasks.publish, - publishBeta: releaseTasks.publishBeta, - prepareDemos: appengineTasks.prepareDemos, - deployDemos: appengineTasks.deployDemos, - deployDemosBeta: appengineTasks.deployDemosBeta, - gitUpdateGithubPages: gitTasks.updateGithubPages, - - // Manually-invokable targets, with prerequisites where required. - messages: buildTasks.messages, // Generate msg/json/en.json et al. - clean: gulp.parallel(buildTasks.cleanBuildDir, packageTasks.cleanReleaseDir), - test: testTasks.test, - testGenerators: testTasks.generators, - buildAdvancedCompilationTest: buildTasks.buildAdvancedCompilationTest, - gitCreateRC: gitTasks.createRC, - docs: docsTasks.docs, - - // Legacy targets, to be deleted. - recompile: releaseTasks.recompile, - gitSyncDevelop: gitTasks.syncDevelop, - gitSyncMaster: gitTasks.syncMaster, -}; diff --git a/gulpfile.mjs b/gulpfile.mjs new file mode 100644 index 00000000000..fd3de3bde8c --- /dev/null +++ b/gulpfile.mjs @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Gulp script to build Blockly for Node & NPM. + * Run this script by calling "npm install" in this directory. + */ +/* eslint-env node */ + +// Needed to prevent prettier from munging exports order, due to +// https://github.com/simonhaenisch/prettier-plugin-organize-imports/issues/146 +// - but has the unfortunate side effect of suppressing ordering of +// imports too: +// +// organize-imports-ignore + +import {parallel} from 'gulp'; +import { + deployDemos, + deployDemosBeta, + prepareDemos, +} from './scripts/gulpfiles/appengine_tasks.mjs'; +import { + build, + buildAdvancedCompilationTest, + cleanBuildDir, + langfiles, + messages, + minify, + tsc, +} from './scripts/gulpfiles/build_tasks.mjs'; +import {docs} from './scripts/gulpfiles/docs_tasks.mjs'; +import { + createRC, + syncDevelop, + syncMaster, + updateGithubPages, +} from './scripts/gulpfiles/git_tasks.mjs'; +import {cleanReleaseDir, pack} from './scripts/gulpfiles/package_tasks.mjs'; +import { + publish, + publishBeta, + recompile, +} from './scripts/gulpfiles/release_tasks.mjs'; +import {generators, test} from './scripts/gulpfiles/test_tasks.mjs'; + +const clean = parallel(cleanBuildDir, cleanReleaseDir); + +// Default target if gulp invoked without specifying. +export default build; + +// Main sequence targets. They already invoke prerequisites. Listed +// in typical order of invocation, and strictly listing prerequisites +// before dependants. +// +// prettier-ignore +export { + langfiles, + tsc, + minify, + build, + pack, // Formerly package. + publishBeta, + publish, + prepareDemos, + deployDemosBeta, + deployDemos, + updateGithubPages as gitUpdateGithubPages, +} + +// Manually-invokable targets that also invoke prerequisites where +// required. +// +// prettier-ignore +export { + messages, // Generate msg/json/en.json et al. + clean, + test, + generators as testGenerators, + buildAdvancedCompilationTest, + createRC as gitCreateRC, + docs, +} + +// Legacy targets, to be deleted. +// +// prettier-ignore +export { + recompile, + syncDevelop as gitSyncDevelop, + syncMaster as gitSyncMaster, +} diff --git a/msg/json/en.json b/msg/json/en.json index e7c468d288a..5494d7fb09f 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "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." } diff --git a/msg/json/qqq.json b/msg/json/qqq.json index 5436da59f9d..5e03efc4153 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -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." } diff --git a/msg/messages.js b/msg/messages.js index d0c3e17688a..b7611b4849b 100644 --- a/msg/messages.js +++ b/msg/messages.js @@ -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.'; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f313dcf8b12..21178897ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "blockly", - "version": "12.1.0", + "version": "12.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockly", - "version": "12.1.0", + "version": "12.2.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "jsdom": "26.1.0" }, "devDependencies": { - "@blockly/block-test": "^6.0.4", + "@blockly/block-test": "^7.0.1", "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", @@ -26,11 +26,11 @@ "eslint": "^9.15.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.1", - "eslint-plugin-jsdoc": "^50.5.0", + "eslint-plugin-jsdoc": "^51.3.1", "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20240317.0.0", + "google-closure-compiler": "^20250625.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", @@ -89,29 +89,29 @@ "license": "ISC" }, "node_modules/@blockly/block-test": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-6.0.11.tgz", - "integrity": "sha512-aIgcxkof1gLJtJXKSvmnug9iSXbv5Qilnov4Sa/QNURiWJRxvMNqWiTZJVu/reuCQK4Qm4jadg9R9l+eu7ujvw==", + "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, "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "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", @@ -126,19 +126,6 @@ "blockly": "^12.0.0" } }, - "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==", - "dev": true, - "license": "Apache 2.0", - "engines": { - "node": ">=8.17.0" - }, - "peerDependencies": { - "blockly": "^12.0.0" - } - }, "node_modules/@blockly/dev-tools/node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -209,9 +196,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 +209,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 +222,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 +247,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 +370,32 @@ } }, "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.52.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.52.0.tgz", + "integrity": "sha512-BXuN7BII+8AyNtn57euU2Yxo9yA/KUDNzrpXyi3pfqKmBhhysR6ZWOebFh3vyPoqA3/j1SOvGgucElMGwlXing==", "dev": true, "dependencies": { + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.34.1", "comment-parser": "1.4.1", "esquery": "^1.6.0", "jsdoc-type-pratt-parser": "~4.1.0" }, "engines": { - "node": ">=16" + "node": ">=20.11.0" + } + }, + "node_modules/@es-joy/jsdoccomment/node_modules/@typescript-eslint/types": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@eslint-community/eslint-utils": { @@ -425,11 +427,10 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -440,21 +441,19 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -500,13 +499,15 @@ } }, "node_modules/@eslint/js": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", - "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", + "version": "9.30.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz", + "integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -514,25 +515,35 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@gulp-sourcemaps/identity-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", @@ -714,10 +725,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 +755,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": { @@ -788,6 +800,27 @@ "url": "https://github.com/sponsors/jdesrosiers" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -920,24 +953,24 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.48.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.48.1.tgz", - "integrity": "sha512-HN9Osa1WxqLM66RaqB5nPAadx+nTIQmY/XtkFdaJvusjG8Tus++QqZtD7KPZDSkhEMGHsYeSyeU8qUzCDUXPjg==", + "version": "7.52.8", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.8.tgz", + "integrity": "sha512-cszYIcjiNscDoMB1CIKZ3My61+JOhpERGlGr54i6bocvGLrcL/wo9o+RNXMBrb7XgLtKaizZWUpqRduQuHQLdg==", "dev": true, "dependencies": { - "@microsoft/api-extractor-model": "7.30.1", + "@microsoft/api-extractor-model": "7.30.6", "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.10.1", + "@rushstack/node-core-library": "5.13.1", "@rushstack/rig-package": "0.5.3", - "@rushstack/terminal": "0.14.4", - "@rushstack/ts-command-line": "4.23.2", + "@rushstack/terminal": "0.15.3", + "@rushstack/ts-command-line": "5.0.1", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", - "typescript": "5.4.2" + "typescript": "5.8.2" }, "bin": { "api-extractor": "bin/api-extractor" @@ -955,102 +988,6 @@ "@rushstack/node-core-library": "5.13.1" } }, - "node_modules/@microsoft/api-extractor/node_modules/@microsoft/api-extractor-model": { - "version": "7.30.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.1.tgz", - "integrity": "sha512-CTS2PlASJHxVY8hqHORVb1HdECWOEMcMnM6/kDkPr0RZapAFSIHhg9D4jxuE8g+OWYHtPc10LCpmde5pylTRlA==", - "dev": true, - "dependencies": { - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.10.1" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/@rushstack/node-core-library": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.10.1.tgz", - "integrity": "sha512-BSb/KcyBHmUQwINrgtzo6jiH0HlGFmrUy33vO6unmceuVKTEyL2q+P0fQq2oB5hvXVWOEUhxB2QvlkZluvUEmg==", - "dev": true, - "dependencies": { - "ajv": "~8.13.0", - "ajv-draft-04": "~1.0.0", - "ajv-formats": "~3.0.1", - "fs-extra": "~7.0.1", - "import-lazy": "~4.0.0", - "jju": "~1.4.0", - "resolve": "~1.22.1", - "semver": "~7.5.4" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@microsoft/api-extractor/node_modules/@rushstack/terminal": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.14.4.tgz", - "integrity": "sha512-NxACqERW0PHq8Rpq1V6v5iTHEwkRGxenjEW+VWqRYQ8T9puUzgmGHmEZUaUEDHAe9Qyvp0/Ew04sAiQw9XjhJg==", - "dev": true, - "dependencies": { - "@rushstack/node-core-library": "5.10.1", - "supports-color": "~8.1.1" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@microsoft/api-extractor/node_modules/@rushstack/ts-command-line": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.2.tgz", - "integrity": "sha512-JJ7XZX5K3ThBBva38aomgsPv1L7FV6XmSOcR6HtM7HDFZJkepqT65imw26h9ggGqMjsY0R9jcl30tzKcVj9aOQ==", - "dev": true, - "dependencies": { - "@rushstack/terminal": "0.14.4", - "@types/argparse": "1.0.38", - "argparse": "~1.0.9", - "string-argv": "~0.3.1" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/ajv": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", - "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", - "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/@microsoft/api-extractor/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@microsoft/api-extractor/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/@microsoft/api-extractor/node_modules/minimatch": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", @@ -1072,25 +1009,10 @@ "node": ">=0.10.0" } }, - "node_modules/@microsoft/api-extractor/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/@microsoft/api-extractor/node_modules/typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -1140,28 +1062,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.3", - "eventsource": "^3.0.2", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1212,15 +1112,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": { @@ -1474,9 +1375,9 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, "node_modules/@types/expect": { @@ -2024,24 +1925,10 @@ "node": ">=6.5" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -2250,30 +2137,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/archiver-utils/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/archiver-utils/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2396,30 +2259,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/archiver/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/archiver/node_modules/buffer-crc32": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", @@ -2802,51 +2641,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2881,6 +2675,30 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -3196,30 +3014,6 @@ "node": ">= 14" } }, - "node_modules/compress-commons/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/compress-commons/node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -3359,40 +3153,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -3411,26 +3171,6 @@ "safe-buffer": "~5.1.1" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/copy-props": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-4.0.0.tgz", @@ -3450,20 +3190,6 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/corser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", @@ -3495,31 +3221,7 @@ "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/crc32-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "node": ">= 14" } }, "node_modules/crc32-stream/node_modules/readable-stream": { @@ -3693,9 +3395,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" }, @@ -3796,16 +3499,6 @@ "node": ">= 14" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3834,10 +3527,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" } @@ -4000,29 +3694,12 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/encoding-sniffer": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", @@ -4088,12 +3765,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", @@ -4160,13 +3831,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4213,24 +3877,22 @@ } }, "node_modules/eslint": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", - "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", + "version": "9.30.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz", + "integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.26.0", - "@eslint/plugin-kit": "^0.2.8", + "@eslint/js": "9.30.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", - "@modelcontextprotocol/sdk": "^1.8.0", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -4238,9 +3900,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4254,8 +3916,7 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "zod": "^3.24.2" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -4288,49 +3949,50 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz", - "integrity": "sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw==", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, - "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, "peerDependencies": { "eslint": ">=7.0.0" } }, "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": "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==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.49.0", + "@es-joy/jsdoccomment": "~0.52.0", "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.4.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" + "node": ">=20.11.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, "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 +4011,13 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz", + "integrity": "sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==", "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,49 +4040,11 @@ } } }, - "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", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -4446,9 +4069,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4470,14 +4093,14 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4487,9 +4110,9 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4528,7 +4151,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -4554,16 +4176,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -4598,29 +4210,6 @@ "node": ">=0.8.x" } }, - "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -4633,65 +4222,6 @@ "node": ">=0.10.0" } }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" - } - }, "node_modules/ext": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", @@ -4905,24 +4435,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5058,12 +4570,12 @@ } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -5086,58 +4598,6 @@ "node": ">=12.20.0" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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, - "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, - "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, - "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", @@ -5328,15 +4788,14 @@ } }, "node_modules/glob": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", - "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "dev": true, - "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -5364,9 +4823,9 @@ } }, "node_modules/glob-stream": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.2.tgz", - "integrity": "sha512-R8z6eTB55t3QeZMmU1C+Gv+t5UnNRkA55c5yo67fAVfxODxieTwsjNG7utxS/73NdP1NbDgCrhVEg2h00y4fFw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.3.tgz", + "integrity": "sha512-fqZVj22LtFJkHODT+M4N1RJQ3TjnnQhfE9GwZI8qXscYarnhpip70poMldRnP8ipQ/w0B621kOhfc53/J9bd/A==", "dev": true, "dependencies": { "@gulpjs/to-absolute-glob": "^4.0.0", @@ -5407,24 +4866,13 @@ "node": ">= 10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { "node": "20 || >=22" @@ -5476,9 +4924,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": { @@ -5501,39 +4949,40 @@ } }, "node_modules/google-closure-compiler": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20240317.0.0.tgz", - "integrity": "sha512-PlC5aU2vwsypKbxyFNXOW4psDZfhDoOr2dCwuo8VcgQji+HVIgRi2lviO66x2SfTi0ilm3kI6rq/RSdOMFczcQ==", + "version": "20250625.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20250625.0.0.tgz", + "integrity": "sha512-FQ6yKCRYwo4493Rq6lZrxpmWuJGZuuSruCdtArptkoThadzw4TM0YvQJvwRYnQDUpjj6/x7G14l2n/+8G39AIA==", "dev": true, "dependencies": { - "chalk": "4.x", - "google-closure-compiler-java": "^20240317.0.0", + "chalk": "5.x", + "google-closure-compiler-java": "^20250625.0.0", "minimist": "1.x", - "vinyl": "2.x", + "vinyl": "3.x", "vinyl-sourcemaps-apply": "^0.2.0" }, "bin": { "google-closure-compiler": "cli.js" }, "engines": { - "node": ">=10" + "node": ">=18" }, "optionalDependencies": { - "google-closure-compiler-linux": "^20240317.0.0", - "google-closure-compiler-osx": "^20240317.0.0", - "google-closure-compiler-windows": "^20240317.0.0" + "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" } }, "node_modules/google-closure-compiler-java": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20240317.0.0.tgz", - "integrity": "sha512-oWURPChjcCrVfiQOuVtpSoUJVvtOYo41JGEQ2qtArsTGmk/DpWh40vS6hitwKRM/0YzJX/jYUuyt9ibuXXJKmg==", + "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 }, "node_modules/google-closure-compiler-linux": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20240317.0.0.tgz", - "integrity": "sha512-dYLtcbbJdbbBS0lTy9SzySdVv/aGkpyTekQiW4ADhT/i1p1b4r0wQTKj6kpVVmFvbZ6t9tW/jbXc9EXXNUahZw==", + "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==", "cpu": [ "x32", "x64" @@ -5544,13 +4993,24 @@ "linux" ] }, - "node_modules/google-closure-compiler-osx": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-osx/-/google-closure-compiler-osx-20240317.0.0.tgz", - "integrity": "sha512-0mABwjD4HP11rikFd8JRIb9OgPqn9h3o3wS0otufMfmbwS7zRpnnoJkunifhORl3VoR1gFm6vcTC9YziTEFdOw==", + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "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==", "cpu": [ - "x32", - "x64", "arm64" ], "dev": true, @@ -5560,9 +5020,9 @@ ] }, "node_modules/google-closure-compiler-windows": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20240317.0.0.tgz", - "integrity": "sha512-fTueVFzNOWURFlXZmrFkAB7yA+jzpA2TeDOYeBEFwVlVGHwi8PV3Q9vCIWlbkE8wLpukKEg5wfRHYrLwVPINCA==", + "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==", "cpu": [ "x32", "x64" @@ -5573,6 +5033,33 @@ "win32" ] }, + "node_modules/google-closure-compiler/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/google-closure-compiler/node_modules/vinyl": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", + "dev": true, + "dependencies": { + "clone": "^2.1.2", + "remove-trailing-separator": "^1.1.0", + "replace-ext": "^2.0.0", + "teex": "^1.0.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5606,15 +5093,15 @@ "license": "MIT" }, "node_modules/gulp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.0.tgz", - "integrity": "sha512-S8Z8066SSileaYw1S2N1I64IUc/myI2bqe2ihOBzO6+nKpvNSg7ZcWJt/AwF8LC/NVN+/QZ560Cb/5OPsyhkhg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.1.tgz", + "integrity": "sha512-PErok3DZSA5WGMd6XXV3IRNO0mlB+wW3OzhFJLEec1jSERg2j1bxJ6e5Fh6N6fn3FH2T9AP4UYNb/pYlADB9sA==", "dev": true, "dependencies": { "glob-watcher": "^6.0.0", - "gulp-cli": "^3.0.0", + "gulp-cli": "^3.1.0", "undertaker": "^2.0.0", - "vinyl-fs": "^4.0.0" + "vinyl-fs": "^4.0.2" }, "bin": { "gulp": "bin/gulp.js" @@ -5624,9 +5111,9 @@ } }, "node_modules/gulp-cli": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.0.0.tgz", - "integrity": "sha512-RtMIitkT8DEMZZygHK2vEuLPqLPAFB4sntSxg4NoDta7ciwGZ18l7JuhCTiS5deOJi2IoK0btE+hs6R4sfj7AA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.1.0.tgz", + "integrity": "sha512-zZzwlmEsTfXcxRKiCHsdyjZZnFvXWM4v1NqBJSYbuApkvVKivjcmOS2qruAJ+PkEHLFavcDKH40DPc1+t12a9Q==", "dev": true, "dependencies": { "@gulpjs/messages": "^1.1.0", @@ -5634,7 +5121,7 @@ "copy-props": "^4.0.0", "gulplog": "^2.2.0", "interpret": "^3.1.1", - "liftoff": "^5.0.0", + "liftoff": "^5.0.1", "mute-stdout": "^2.0.0", "replace-homedir": "^2.0.0", "semver-greatest-satisfied-range": "^2.0.0", @@ -6120,23 +5607,6 @@ "entities": "^4.5.0" } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/http-proxy": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", @@ -6346,16 +5816,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -6616,11 +6076,10 @@ } }, "node_modules/jackspeak": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", - "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -6924,9 +6383,9 @@ } }, "node_modules/liftoff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.0.tgz", - "integrity": "sha512-a5BQjbCHnB+cy+gsro8lXJ4kZluzOijzJ1UVVfyJYZC+IP2pLv1h4+aysQeKuTmyO8NAqfyQAk4HWaP/HjcKTg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.1.tgz", + "integrity": "sha512-wwLXMbuxSF8gMvubFcFRp56lkFV69twvbU5vDPbaw+Q+/rF8j0HKjGbIdlSi+LuJm9jf7k9PB+nTxnsLMPcv2Q==", "dev": true, "dependencies": { "extend": "^3.0.2", @@ -7150,17 +6609,7 @@ "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" + "node": ">= 0.4" } }, "node_modules/memoizee": { @@ -7185,19 +6634,6 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7234,29 +6670,6 @@ "node": ">=4" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7298,29 +6711,28 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.1.tgz", + "integrity": "sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==", "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 +6792,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 +6816,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": { @@ -7511,16 +6910,6 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -7678,19 +7067,6 @@ "node": ">=0.10.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7855,17 +7231,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 +7259,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", @@ -7923,16 +7303,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/patch-package": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", @@ -8149,16 +7519,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/plugin-error": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", @@ -8254,11 +7614,10 @@ } }, "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.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -8322,20 +7681,6 @@ "node": ">=0.4.0" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -8450,32 +7795,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8821,40 +8140,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -8965,29 +8250,6 @@ "node": ">= 10.13.0" } }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/serialize-error": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-11.0.3.tgz", @@ -9012,35 +8274,12 @@ "randombytes": "^2.1.0" } }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "dev": true }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9186,12 +8425,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", @@ -9306,16 +8539,6 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/stream-composer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", @@ -9566,31 +8789,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": { @@ -9716,16 +8933,6 @@ "node": ">=10.13.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -9817,21 +9024,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -9954,16 +9146,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -10028,16 +9210,6 @@ "node": ">= 10.13.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vinyl": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", @@ -10069,13 +9241,12 @@ } }, "node_modules/vinyl-contents/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -10085,13 +9256,13 @@ } }, "node_modules/vinyl-fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.0.tgz", - "integrity": "sha512-7GbgBnYfaquMk3Qu9g22x000vbYkOex32930rBnc3qByw6HfMEAoELjCjoJv4HuEQxHAurT+nvMHm6MnJllFLw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.2.tgz", + "integrity": "sha512-XRFwBLLTl8lRAOYiBqxY279wY46tVxLaRhSwo3GzKEuLz1giffsOquWWboD/haGf5lx+JyTigCFfe7DWHoARIA==", "dev": true, "dependencies": { "fs-mkdirp-stream": "^2.0.1", - "glob-stream": "^8.0.0", + "glob-stream": "^8.0.3", "graceful-fs": "^4.2.11", "iconv-lite": "^0.6.3", "is-valid-glob": "^1.0.0", @@ -10102,7 +9273,7 @@ "streamx": "^2.14.0", "to-through": "^3.0.0", "value-or-function": "^4.0.0", - "vinyl": "^3.0.0", + "vinyl": "^3.0.1", "vinyl-sourcemap": "^2.0.0" }, "engines": { @@ -10110,13 +9281,12 @@ } }, "node_modules/vinyl-fs/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -10149,13 +9319,12 @@ "dev": true }, "node_modules/vinyl-sourcemap/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -10357,10 +9526,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", @@ -10596,30 +9766,6 @@ "node": ">= 14" } }, - "node_modules/zip-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/zip-stream/node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -10664,26 +9810,6 @@ "dependencies": { "safe-buffer": "~5.2.0" } - }, - "node_modules/zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } } } } diff --git a/package.json b/package.json index eab39b16cde..d464ae5f171 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "12.1.0", + "version": "12.2.0", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" @@ -33,7 +33,7 @@ "lint:fix": "eslint . --fix", "langfiles": "gulp langfiles", "minify": "gulp minify", - "package": "gulp package", + "package": "gulp pack", "postinstall": "patch-package", "prepareDemos": "gulp prepareDemos", "publish": "npm ci && gulp publish", @@ -100,7 +100,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@blockly/block-test": "^6.0.4", + "@blockly/block-test": "^7.0.1", "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", @@ -113,11 +113,11 @@ "eslint": "^9.15.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.1", - "eslint-plugin-jsdoc": "^50.5.0", + "eslint-plugin-jsdoc": "^51.3.1", "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20240317.0.0", + "google-closure-compiler": "^20250625.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", diff --git a/scripts/gulpfiles/appengine_tasks.js b/scripts/gulpfiles/appengine_tasks.mjs similarity index 86% rename from scripts/gulpfiles/appengine_tasks.js rename to scripts/gulpfiles/appengine_tasks.mjs index ddbd2f45f90..7545343832f 100644 --- a/scripts/gulpfiles/appengine_tasks.js +++ b/scripts/gulpfiles/appengine_tasks.mjs @@ -8,16 +8,16 @@ * @fileoverview Gulp script to deploy Blockly demos on appengine. */ -const gulp = require('gulp'); +import * as gulp from 'gulp'; -const fs = require('fs'); -const path = require('path'); -const execSync = require('child_process').execSync; -const buildTasks = require('./build_tasks.js'); -const packageTasks = require('./package_tasks.js'); -const {rimraf} = require('rimraf'); +import * as fs from 'fs'; +import * as path from 'path'; +import {execSync} from 'child_process'; +import * as buildTasks from './build_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; +import {rimraf} from 'rimraf'; -const packageJson = require('../../package.json'); const demoTmpDir = '../_deploy'; const demoStaticTmpDir = '../_deploy/static'; @@ -123,7 +123,7 @@ function deployToAndClean(demoVersion) { */ function getDemosVersion() { // Replace all '.' with '-' e.g. 9-3-3-beta-2 - return packageJson.version.replace(/\./g, '-'); + return getPackageJson().version.replace(/\./g, '-'); } /** @@ -162,7 +162,7 @@ function deployBetaAndClean(done) { * * Prerequisites (invoked): clean, build */ -const prepareDemos = gulp.series( +export const prepareDemos = gulp.series( prepareDeployDir, gulp.parallel( gulp.series( @@ -180,16 +180,9 @@ const prepareDemos = gulp.series( /** * Deploys demos. */ -const deployDemos = gulp.series(prepareDemos, deployAndClean); +export const deployDemos = gulp.series(prepareDemos, deployAndClean); /** * Deploys beta version of demos (version appended with -beta). */ -const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - deployDemos: deployDemos, - deployDemosBeta: deployDemosBeta, - prepareDemos: prepareDemos -}; +export const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean); diff --git a/scripts/gulpfiles/build_tasks.js b/scripts/gulpfiles/build_tasks.mjs similarity index 92% rename from scripts/gulpfiles/build_tasks.js rename to scripts/gulpfiles/build_tasks.mjs index a00c1b17dc3..4b82402fb52 100644 --- a/scripts/gulpfiles/build_tasks.js +++ b/scripts/gulpfiles/build_tasks.mjs @@ -8,25 +8,28 @@ * @fileoverview Gulp script to build Blockly for Node & NPM. */ -const gulp = require('gulp'); -gulp.replace = require('gulp-replace'); -gulp.rename = require('gulp-rename'); -gulp.sourcemaps = require('gulp-sourcemaps'); +import * as gulp from 'gulp'; +import replace from 'gulp-replace'; +import rename from 'gulp-rename'; +import sourcemaps from 'gulp-sourcemaps'; -const path = require('path'); -const fs = require('fs'); -const fsPromises = require('fs/promises'); -const {exec, execSync} = require('child_process'); +import * as path from 'path'; +import * as fs from 'fs'; +import * as fsPromises from 'fs/promises'; +import {exec, execSync} from 'child_process'; -const {globSync} = require('glob'); -const closureCompiler = require('google-closure-compiler').gulp(); -const argv = require('yargs').argv; -const {rimraf} = require('rimraf'); +import {globSync} from 'glob'; +import {gulp as closureCompiler} from 'google-closure-compiler'; +import yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; +import {rimraf} from 'rimraf'; -const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} = require('./config'); -const {getPackageJson} = require('./helper_tasks'); +import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} from './config.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; -const {posixPath, quote} = require('../helpers'); +import {posixPath, quote} from '../helpers.js'; + +const argv = yargs(hideBin(process.argv)).parse(); //////////////////////////////////////////////////////////// // Build // @@ -182,7 +185,7 @@ function stripApacheLicense() { // Closure Compiler preserves dozens of Apache licences in the Blockly code. // Remove these if they belong to Google or MIT. // MIT's permission to do this is logged in Blockly issue #2412. - return gulp.replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n'); + return replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n'); // Replace with the same number of lines so that source-maps are not affected. } @@ -240,7 +243,6 @@ const JSCOMP_ERROR = [ 'underscore', 'unknownDefines', // 'unusedLocalVariables', // Disabled; see note in JSCOMP_OFF. - 'unusedPrivateMembers', 'uselessCode', 'untranspilableFeatures', // 'visibility', // Disabled; see note in JSCOMP_OFF. @@ -306,7 +308,7 @@ const JSCOMP_OFF = [ * Builds Blockly as a JS program, by running tsc on all the files in * the core directory. */ -function buildJavaScript(done) { +export function tsc(done) { execSync( `tsc -outDir "${TSC_OUTPUT_DIR}" -declarationDir "${TYPINGS_BUILD_DIR}"`, {stdio: 'inherit'}); @@ -318,7 +320,7 @@ function buildJavaScript(done) { * This task regenerates msg/json/en.js and msg/json/qqq.js from * msg/messages.js. */ -function generateMessages(done) { +export function messages(done) { // Run js_to_json.py const jsToJsonCmd = `${PYTHON} scripts/i18n/js_to_json.py \ --input_file ${path.join('msg', 'messages.js')} \ @@ -573,10 +575,10 @@ function buildCompiled() { // Fire up compilation pipline. return gulp.src(chunkOptions.js, {base: './'}) .pipe(stripApacheLicense()) - .pipe(gulp.sourcemaps.init()) + .pipe(sourcemaps.init()) .pipe(compile(options)) - .pipe(gulp.rename({suffix: COMPILED_SUFFIX})) - .pipe(gulp.sourcemaps.write('.')) + .pipe(rename({suffix: COMPILED_SUFFIX})) + .pipe(sourcemaps.write('.')) .pipe(gulp.dest(RELEASE_DIR)); } @@ -668,7 +670,7 @@ async function buildLangfileShims() { // (We have to do it this way because messages.js is a script and // not a CJS module with exports.) globalThis.Blockly = {Msg: {}}; - require('../../msg/messages.js'); + await import('../../msg/messages.js'); const exportedNames = Object.keys(globalThis.Blockly.Msg); delete globalThis.Blockly; @@ -689,12 +691,14 @@ ${exportedNames.map((name) => ` ${name},`).join('\n')} } /** - * This task builds Blockly core, blocks and generators together and uses - * Closure Compiler's ADVANCED_COMPILATION mode. + * This task uses Closure Compiler's ADVANCED_COMPILATION mode to + * compile together Blockly core, blocks and generators with a simple + * test app; the purpose is to verify that Blockly is compatible with + * the ADVANCED_COMPILATION mode. * * Prerequisite: buildJavaScript. */ -function buildAdvancedCompilationTest() { +function compileAdvancedCompilationTest() { // If main_compressed.js exists (from a previous run) delete it so that // a later browser-based test won't check it should the compile fail. try { @@ -718,9 +722,9 @@ function buildAdvancedCompilationTest() { }; return gulp.src(srcs, {base: './'}) .pipe(stripApacheLicense()) - .pipe(gulp.sourcemaps.init()) + .pipe(sourcemaps.init()) .pipe(compile(options)) - .pipe(gulp.sourcemaps.write( + .pipe(sourcemaps.write( '.', {includeContent: false, sourceRoot: '../../'})) .pipe(gulp.dest('./tests/compile/')); } @@ -728,7 +732,7 @@ function buildAdvancedCompilationTest() { /** * This task cleans the build directory (by deleting it). */ -function cleanBuildDir() { +export function cleanBuildDir() { // Sanity check. if (BUILD_DIR === '.' || BUILD_DIR === '/') { return Promise.reject(`Refusing to rm -rf ${BUILD_DIR}`); @@ -737,16 +741,13 @@ function cleanBuildDir() { } // Main sequence targets. Each should invoke any immediate prerequisite(s). -exports.cleanBuildDir = cleanBuildDir; -exports.langfiles = gulp.parallel(buildLangfiles, buildLangfileShims); -exports.tsc = buildJavaScript; -exports.minify = gulp.series(exports.tsc, buildCompiled, buildShims); -exports.build = gulp.parallel(exports.minify, exports.langfiles); +// function cleanBuildDir, above +export const langfiles = gulp.parallel(buildLangfiles, buildLangfileShims); +export const minify = gulp.series(tsc, buildCompiled, buildShims); +// function tsc, above +export const build = gulp.parallel(minify, langfiles); // Manually-invokable targets, with prerequisites where required. -exports.messages = generateMessages; // Generate msg/json/en.json et al. -exports.buildAdvancedCompilationTest = - gulp.series(exports.tsc, buildAdvancedCompilationTest); - -// Targets intended only for invocation by scripts; may omit prerequisites. -exports.onlyBuildAdvancedCompilationTest = buildAdvancedCompilationTest; +// function messages, above +export const buildAdvancedCompilationTest = + gulp.series(tsc, compileAdvancedCompilationTest); diff --git a/scripts/gulpfiles/config.js b/scripts/gulpfiles/config.mjs similarity index 70% rename from scripts/gulpfiles/config.js rename to scripts/gulpfiles/config.mjs index 90cd571099d..52e4cd06fe1 100644 --- a/scripts/gulpfiles/config.js +++ b/scripts/gulpfiles/config.mjs @@ -8,7 +8,7 @@ * @fileoverview Common configuration for Gulp scripts. */ -const path = require('path'); +import * as path from 'path'; // Paths are all relative to the repository root. Do not include // trailing slash. @@ -21,21 +21,21 @@ const path = require('path'); // - tests/scripts/update_metadata.sh // Directory to write compiled output to. -exports.BUILD_DIR = 'build'; +export const BUILD_DIR = 'build'; // Directory to write typings output to. -exports.TYPINGS_BUILD_DIR = path.join(exports.BUILD_DIR, 'declarations'); +export const TYPINGS_BUILD_DIR = path.join(BUILD_DIR, 'declarations'); // Directory to write langfile output to. -exports.LANG_BUILD_DIR = path.join(exports.BUILD_DIR, 'msg'); +export const LANG_BUILD_DIR = path.join(BUILD_DIR, 'msg'); // Directory where typescript compiler output can be found. // Matches the value in tsconfig.json: outDir -exports.TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'src'); +export const TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'src'); // Directory for files generated by compiling test code. -exports.TEST_TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'tests'); +export const TEST_TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'tests'); // Directory in which to assemble (and from which to publish) the // blockly npm package. -exports.RELEASE_DIR = 'dist'; +export const RELEASE_DIR = 'dist'; diff --git a/scripts/gulpfiles/docs_tasks.js b/scripts/gulpfiles/docs_tasks.mjs similarity index 94% rename from scripts/gulpfiles/docs_tasks.js rename to scripts/gulpfiles/docs_tasks.mjs index 8820a586f9b..63fdbe66536 100644 --- a/scripts/gulpfiles/docs_tasks.js +++ b/scripts/gulpfiles/docs_tasks.mjs @@ -1,9 +1,9 @@ -const {execSync} = require('child_process'); -const {Extractor} = require('markdown-tables-to-json'); -const fs = require('fs'); -const gulp = require('gulp'); -const header = require('gulp-header'); -const replace = require('gulp-replace'); +import {execSync} from 'child_process'; +import {Extractor} from 'markdown-tables-to-json'; +import * as fs from 'fs'; +import * as gulp from 'gulp'; +import * as header from 'gulp-header'; +import * as replace from 'gulp-replace'; const DOCS_DIR = 'docs'; @@ -140,8 +140,7 @@ const createToc = function(done) { done(); } -const docs = gulp.series( +export const docs = gulp.series( generateApiJson, removeRenames, generateDocs, gulp.parallel(prependBook, createToc)); -module.exports = {docs}; diff --git a/scripts/gulpfiles/git_tasks.js b/scripts/gulpfiles/git_tasks.mjs similarity index 86% rename from scripts/gulpfiles/git_tasks.js rename to scripts/gulpfiles/git_tasks.mjs index 7c320cd8791..2b08e16b38b 100644 --- a/scripts/gulpfiles/git_tasks.js +++ b/scripts/gulpfiles/git_tasks.mjs @@ -8,11 +8,11 @@ * @fileoverview Git-related gulp tasks for Blockly. */ -const gulp = require('gulp'); -const execSync = require('child_process').execSync; +import * as gulp from 'gulp'; +import {execSync} from 'child_process'; -const buildTasks = require('./build_tasks'); -const packageTasks = require('./package_tasks'); +import * as buildTasks from './build_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; const UPSTREAM_URL = 'https://github.com/google/blockly.git'; @@ -63,7 +63,7 @@ function syncBranch(branchName) { * Stash current state, check out develop, and sync with * google/blockly. */ -function syncDevelop() { +export function syncDevelop() { return syncBranch('develop'); }; @@ -71,7 +71,7 @@ function syncDevelop() { * Stash current state, check out master, and sync with * google/blockly. */ -function syncMaster() { +export function syncMaster() { return syncBranch('master'); }; @@ -111,7 +111,7 @@ function checkoutBranch(branchName) { * Create and push an RC branch. * Note that this pushes to google/blockly. */ -const createRC = gulp.series( +export const createRC = gulp.series( syncDevelop(), function(done) { const branchName = getRCBranchName(); @@ -122,7 +122,7 @@ const createRC = gulp.series( ); /** Create the rebuild branch. */ -function createRebuildBranch(done) { +export function createRebuildBranch(done) { const branchName = getRebuildBranchName(); console.log(`make-rebuild-branch: creating branch ${branchName}`); execSync(`git switch -C ${branchName}`, { stdio: 'inherit' }); @@ -130,7 +130,7 @@ function createRebuildBranch(done) { } /** Push the rebuild branch to origin. */ -function pushRebuildBranch(done) { +export function pushRebuildBranch(done) { console.log('push-rebuild-branch: committing rebuild'); execSync('git commit -am "Rebuild"', { stdio: 'inherit' }); const branchName = getRebuildBranchName(); @@ -145,7 +145,7 @@ function pushRebuildBranch(done) { * * Prerequisites (invoked): clean, build. */ -const updateGithubPages = gulp.series( +export const updateGithubPages = gulp.series( function(done) { execSync('git stash save -m "Stash for sync"', { stdio: 'inherit' }); execSync('git switch -C gh-pages', { stdio: 'inherit' }); @@ -165,17 +165,3 @@ const updateGithubPages = gulp.series( done(); } ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - updateGithubPages, - - // Manually-invokable targets that invoke prerequisites. - createRC, - - // Legacy script-only targets, to be deleted. - syncDevelop, - syncMaster, - createRebuildBranch, - pushRebuildBranch, -}; diff --git a/scripts/gulpfiles/helper_tasks.js b/scripts/gulpfiles/helper_tasks.js deleted file mode 100644 index b239d03f5fa..00000000000 --- a/scripts/gulpfiles/helper_tasks.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Any gulp helper functions. - */ - -// Clears the require cache to ensure the package.json is up to date. -function getPackageJson() { - delete require.cache[require.resolve('../../package.json')] - return require('../../package.json'); -} - -module.exports = { - getPackageJson: getPackageJson -} diff --git a/scripts/gulpfiles/helper_tasks.mjs b/scripts/gulpfiles/helper_tasks.mjs new file mode 100644 index 00000000000..2068de106a5 --- /dev/null +++ b/scripts/gulpfiles/helper_tasks.mjs @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Any gulp helper functions. + */ + +import Module from "node:module"; + +const require = Module.createRequire(import.meta.url); + +/** + * Load and return the contents of package.json. + * + * Uses require() rather than import, and clears the require cache, to + * ensure the loaded package.json data is up to date. + */ +export function getPackageJson() { + delete require.cache[require.resolve('../../package.json')]; + return require('../../package.json'); +} + diff --git a/scripts/gulpfiles/package_tasks.js b/scripts/gulpfiles/package_tasks.mjs similarity index 89% rename from scripts/gulpfiles/package_tasks.js rename to scripts/gulpfiles/package_tasks.mjs index 89264a0e3c4..948f855b096 100644 --- a/scripts/gulpfiles/package_tasks.js +++ b/scripts/gulpfiles/package_tasks.mjs @@ -8,20 +8,17 @@ * @fileoverview Gulp tasks to package Blockly for distribution on NPM. */ -const gulp = require('gulp'); -gulp.concat = require('gulp-concat'); -gulp.replace = require('gulp-replace'); -gulp.rename = require('gulp-rename'); -gulp.insert = require('gulp-insert'); -gulp.umd = require('gulp-umd'); -gulp.replace = require('gulp-replace'); +import * as gulp from 'gulp'; +import concat from 'gulp-concat'; +import replace from 'gulp-replace'; +import umd from 'gulp-umd'; -const path = require('path'); -const fs = require('fs'); -const {rimraf} = require('rimraf'); -const build = require('./build_tasks'); -const {getPackageJson} = require('./helper_tasks'); -const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} = require('./config'); +import * as path from 'path'; +import * as fs from 'fs'; +import {rimraf} from 'rimraf'; +import * as build from './build_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} from './config.mjs'; // Path to template files for gulp-umd. const TEMPLATE_DIR = 'scripts/package/templates'; @@ -32,7 +29,7 @@ const TEMPLATE_DIR = 'scripts/package/templates'; * @param {Array} dependencies An array of dependencies to inject. */ function packageUMD(namespace, dependencies, template = 'umd.template') { - return gulp.umd({ + return umd({ dependencies: function () { return dependencies; }, namespace: function () { return namespace; }, exports: function () { return namespace; }, @@ -88,7 +85,7 @@ function packageCoreNode() { function packageLocales() { // Remove references to goog.provide and goog.require. return gulp.src(`${LANG_BUILD_DIR}/*.js`) - .pipe(gulp.replace(/goog\.[^\n]+/g, '')) + .pipe(replace(/goog\.[^\n]+/g, '')) .pipe(packageUMD('Blockly.Msg', [], 'umd-msg.template')) .pipe(gulp.dest(`${RELEASE_DIR}/msg`)); }; @@ -107,7 +104,7 @@ function packageUMDBundle() { `${RELEASE_DIR}/javascript_compressed.js`, ]; return gulp.src(srcs) - .pipe(gulp.concat('blockly.min.js')) + .pipe(concat('blockly.min.js')) .pipe(gulp.dest(`${RELEASE_DIR}`)); }; @@ -140,7 +137,7 @@ function packageUMDBundle() { * @param {Function} done Callback to call when done. */ function packageLegacyEntrypoints(done) { - for (entrypoint of [ + for (const entrypoint of [ 'core', 'blocks', 'dart', 'javascript', 'lua', 'php', 'python' ]) { const bundle = @@ -218,14 +215,14 @@ function packageDTS() { .pipe(gulp.src(`${TYPINGS_BUILD_DIR}/**/*.d.ts`, {ignore: [ `${TYPINGS_BUILD_DIR}/blocks/**/*`, ]})) - .pipe(gulp.replace('AnyDuringMigration', 'any')) + .pipe(replace('AnyDuringMigration', 'any')) .pipe(gulp.dest(RELEASE_DIR)); }; /** * This task cleans the release directory (by deleting it). */ -function cleanReleaseDir() { +export function cleanReleaseDir() { // Sanity check. if (RELEASE_DIR === '.' || RELEASE_DIR === '/') { return Promise.reject(`Refusing to rm -rf ${RELEASE_DIR}`); @@ -237,9 +234,13 @@ function cleanReleaseDir() { * This task prepares the files to be included in the NPM by copying * them into the release directory. * + * This task was formerly called "package" but was renamed in + * preparation for porting gulpfiles to ESM because "package" is a + * reserved word. + * * Prerequisite: build. */ -const package = gulp.series( +export const pack = gulp.series( gulp.parallel( build.cleanBuildDir, cleanReleaseDir), @@ -254,9 +255,3 @@ const package = gulp.series( packageReadme, packageDTS) ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - cleanReleaseDir: cleanReleaseDir, - package: package, -}; diff --git a/scripts/gulpfiles/release_tasks.js b/scripts/gulpfiles/release_tasks.mjs similarity index 87% rename from scripts/gulpfiles/release_tasks.js rename to scripts/gulpfiles/release_tasks.mjs index f2545c7b92b..a678a4f2436 100644 --- a/scripts/gulpfiles/release_tasks.js +++ b/scripts/gulpfiles/release_tasks.mjs @@ -8,15 +8,15 @@ * @fileoverview Gulp scripts for releasing Blockly. */ -const execSync = require('child_process').execSync; -const fs = require('fs'); -const gulp = require('gulp'); -const readlineSync = require('readline-sync'); +import {execSync} from 'child_process'; +import * as fs from 'fs'; +import * as gulp from 'gulp'; +import * as readlineSync from 'readline-sync'; -const gitTasks = require('./git_tasks'); -const packageTasks = require('./package_tasks'); -const {getPackageJson} = require('./helper_tasks'); -const {RELEASE_DIR} = require('./config'); +import * as gitTasks from './git_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import {RELEASE_DIR} from './config.mjs'; // Gets the current major version. @@ -147,17 +147,17 @@ function updateBetaVersion(done) { } // Rebuild, package and publish to npm. -const publish = gulp.series( - packageTasks.package, // Does clean + build. +export const publish = gulp.series( + packageTasks.pack, // Does clean + build. checkBranch, checkReleaseDir, loginAndPublish ); // Rebuild, package and publish a beta version of Blockly. -const publishBeta = gulp.series( +export const publishBeta = gulp.series( updateBetaVersion, - packageTasks.package, // Does clean + build. + packageTasks.pack, // Does clean + build. checkBranch, checkReleaseDir, loginAndPublishBeta @@ -165,19 +165,10 @@ const publishBeta = gulp.series( // Switch to a new branch, update the version number, build Blockly // and check in the resulting built files. -const recompileDevelop = gulp.series( +export const recompile = gulp.series( gitTasks.syncDevelop(), gitTasks.createRebuildBranch, updateVersionPrompt, - packageTasks.package, // Does clean + build. + packageTasks.pack, // Does clean + build. gitTasks.pushRebuildBranch ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - publishBeta, - publish, - - // Legacy target, to be deleted. - recompile: recompileDevelop, -}; diff --git a/scripts/gulpfiles/test_tasks.js b/scripts/gulpfiles/test_tasks.mjs similarity index 94% rename from scripts/gulpfiles/test_tasks.js rename to scripts/gulpfiles/test_tasks.mjs index 236a21d7759..d4b73cdb3c1 100644 --- a/scripts/gulpfiles/test_tasks.js +++ b/scripts/gulpfiles/test_tasks.mjs @@ -9,19 +9,19 @@ */ /* eslint-env node */ -const asyncDone = require('async-done'); -const gulp = require('gulp'); -const gzip = require('gulp-gzip'); -const fs = require('fs'); -const path = require('path'); -const {execSync} = require('child_process'); -const {rimraf} = require('rimraf'); +import asyncDone from 'async-done'; +import * as gulp from 'gulp'; +import gzip from 'gulp-gzip'; +import * as fs from 'fs'; +import * as path from 'path'; +import {execSync} from 'child_process'; +import {rimraf} from 'rimraf'; -const {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} = require('./config'); +import {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} from './config.mjs'; -const {runMochaTestsInBrowser} = require('../../tests/mocha/webdriver.js'); -const {runGeneratorsInBrowser} = require('../../tests/generators/webdriver.js'); -const {runCompileCheckInBrowser} = require('../../tests/compile/webdriver.js'); +import {runMochaTestsInBrowser} from '../../tests/mocha/webdriver.js'; +import {runGeneratorsInBrowser} from '../../tests/generators/webdriver.js'; +import {runCompileCheckInBrowser} from '../../tests/compile/webdriver.js'; const OUTPUT_DIR = 'build/generators'; const GOLDEN_DIR = 'tests/generators/golden'; @@ -321,7 +321,7 @@ function checkResult(suffix) { * Run generator tests inside a browser and check the results. * @return {Promise} Asynchronous result. */ -async function generators() { +export async function generators() { return runTestTask('generators', async () => { // Clean up. rimraf.sync(OUTPUT_DIR); @@ -396,10 +396,6 @@ const tasks = [ advancedCompileInBrowser ]; -const test = gulp.series(...tasks, reportTestResult); +export const test = gulp.series(...tasks, reportTestResult); -module.exports = { - test, - generators, -}; diff --git a/tests/browser/test/delete_blocks_test.mjs b/tests/browser/test/delete_blocks_test.mjs index a407ad0600f..5c2499c41f3 100644 --- a/tests/browser/test/delete_blocks_test.mjs +++ b/tests/browser/test/delete_blocks_test.mjs @@ -8,6 +8,7 @@ import * as chai from 'chai'; import {Key} from 'webdriverio'; import { clickBlock, + clickWorkspace, contextMenuSelect, getAllBlocks, getBlockElementById, @@ -176,8 +177,7 @@ suite('Delete blocks', function (done) { ); }); - // TODO(#9029) enable this test once deleting a block doesn't lose focus - test.skip('Undo block deletion', async function () { + test('Undo block deletion', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. await clickBlock(this.browser, this.firstBlock.id, {button: 1}); @@ -204,6 +204,7 @@ suite('Delete blocks', function (done) { await this.browser.keys([Key.Ctrl, 'z']); await this.browser.pause(PAUSE_TIME); // Redo + await clickWorkspace(this.browser); await this.browser.keys([Key.Ctrl, Key.Shift, 'z']); await this.browser.pause(PAUSE_TIME); const after = (await getAllBlocks(this.browser)).length; diff --git a/tests/browser/test/extensive_test.mjs b/tests/browser/test/extensive_test.mjs index bef8bc9345d..48c066c399d 100644 --- a/tests/browser/test/extensive_test.mjs +++ b/tests/browser/test/extensive_test.mjs @@ -40,8 +40,7 @@ suite('This tests loading Large Configuration and Deletion', function (done) { chai.assert.equal(allBlocks.length, 10); }); - // TODO(#8793) Re-enable test after deleting a block updates focus correctly. - test.skip('undoing delete block results in the correct number of blocks', async function () { + test('undoing delete block results in the correct number of blocks', async function () { await this.browser.keys([Key.Ctrl, 'z']); await this.browser.pause(PAUSE_TIME); const allBlocks = await getAllBlocks(this.browser); diff --git a/tests/browser/test/procedure_test.mjs b/tests/browser/test/procedure_test.mjs index c01eb49561c..d1990fddc4a 100644 --- a/tests/browser/test/procedure_test.mjs +++ b/tests/browser/test/procedure_test.mjs @@ -11,9 +11,8 @@ import * as chai from 'chai'; import { connect, - getBlockTypeFromCategory, - getNthBlockOfCategory, - getSelectedBlockElement, + dragBlockTypeFromFlyout, + dragNthBlockFromFlyout, PAUSE_TIME, testFileLocations, testSetup, @@ -33,43 +32,41 @@ suite('Testing Connecting Blocks', function (done) { test('Testing Procedure', async function () { // Drag out first function - let proceduresDefReturn = await getBlockTypeFromCategory( + const doSomething = await dragBlockTypeFromFlyout( this.browser, 'Functions', 'procedures_defreturn', + 50, + 20, ); - await proceduresDefReturn.dragAndDrop({x: 50, y: 20}); - const doSomething = await getSelectedBlockElement(this.browser); - // Drag out second function. - proceduresDefReturn = await getBlockTypeFromCategory( + const doSomething2 = await dragBlockTypeFromFlyout( this.browser, 'Functions', 'procedures_defreturn', + 50, + 20, ); - await proceduresDefReturn.dragAndDrop({x: 300, y: 200}); - const doSomething2 = await getSelectedBlockElement(this.browser); - // Drag out numeric - const mathNumeric = await getBlockTypeFromCategory( + const numeric = await dragBlockTypeFromFlyout( this.browser, 'Math', 'math_number', + 50, + 20, ); - await mathNumeric.dragAndDrop({x: 50, y: 20}); - const numeric = await getSelectedBlockElement(this.browser); // Connect numeric to first procedure await connect(this.browser, numeric, 'OUTPUT', doSomething, 'RETURN'); // Drag out doSomething caller from flyout. - const doSomethingFlyout = await getNthBlockOfCategory( + const doSomethingCaller = await dragNthBlockFromFlyout( this.browser, 'Functions', 3, + 50, + 20, ); - await doSomethingFlyout.dragAndDrop({x: 50, y: 20}); - const doSomethingCaller = await getSelectedBlockElement(this.browser); // Connect the doSomething caller to doSomething2 await connect( @@ -81,22 +78,22 @@ suite('Testing Connecting Blocks', function (done) { ); // Drag out print from flyout. - const printFlyout = await getBlockTypeFromCategory( + const print = await dragBlockTypeFromFlyout( this.browser, 'Text', 'text_print', + 50, + 0, ); - await printFlyout.dragAndDrop({x: 50, y: 20}); - const print = await getSelectedBlockElement(this.browser); // Drag out doSomething2 caller from flyout. - const doSomething2Flyout = await getNthBlockOfCategory( + const doSomething2Caller = await dragNthBlockFromFlyout( this.browser, 'Functions', 4, + 50, + 20, ); - await doSomething2Flyout.dragAndDrop({x: 130, y: 20}); - const doSomething2Caller = await getSelectedBlockElement(this.browser); // Connect doSomething2 caller with print. await connect(this.browser, doSomething2Caller, 'OUTPUT', print, 'TEXT'); diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index 04a192a46a7..6cf4986fce5 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -62,6 +62,8 @@ export async function driverSetup() { // Use Selenium to bring up the page console.log('Starting webdriverio...'); driver = await webdriverio.remote(options); + driver.setWindowSize(800, 600); + driver.setViewport({width: 800, height: 600}); return driver; } @@ -170,43 +172,52 @@ export async function getBlockElementById(browser, id) { * @return A Promise that resolves when the actions are completed. */ export async function clickBlock(browser, blockId, clickOptions) { - const findableId = 'clickTargetElement'; // In the browser context, find the element that we want and give it a findable ID. - await browser.execute( - (blockId, newElemId) => { - const block = Blockly.getMainWorkspace().getBlockById(blockId); - // Ensure the block we want to click is within the viewport. - Blockly.getMainWorkspace().scrollBoundsIntoView( - block.getBoundingRectangleWithoutChildren(), - 10, - ); + const elem = await getTargetableBlockElement(browser, blockId, false); + await elem.click(clickOptions); +} + +/** + * Find an element on the block that is suitable for a click or drag. + * + * We can't always use the block's SVG root because clicking will always happen + * in the middle of the block's bounds (including children) by default, which + * causes problems if it has holes (e.g. statement inputs). Instead, this tries + * to get the first text field on the block. It falls back on the block's SVG root. + * @param browser The active WebdriverIO Browser object. + * @param blockId The id of the block to click, as an interactable element. + * @param toolbox True if this block is in the toolbox (which must be open already). + * @return A Promise that returns an appropriate element. + */ +async function getTargetableBlockElement(browser, blockId, toolbox) { + const id = await browser.execute( + (blockId, toolbox, newElemId) => { + const ws = toolbox + ? Blockly.getMainWorkspace().getFlyout().getWorkspace() + : Blockly.getMainWorkspace(); + const block = ws.getBlockById(blockId); + // Ensure the block we want to click/drag is within the viewport. + ws.scrollBoundsIntoView(block.getBoundingRectangleWithoutChildren(), 10); if (!block.isCollapsed()) { for (const input of block.inputList) { for (const field of input.fieldRow) { if (field instanceof Blockly.FieldLabel) { - field.getSvgRoot().id = newElemId; - return; + // Expose the id of the element we want to target + field.getSvgRoot().setAttribute('data-id', field.id_); + return field.getSvgRoot().id; } } } } - // No label field found. Fall back to the block's SVG root. - block.getSvgRoot().id = newElemId; + // No label field found. Fall back to the block's SVG root, which should + // already use the block id. + return block.id; }, blockId, - findableId, + toolbox, ); - // In the test context, get the Webdriverio Element that we've identified. - const elem = await browser.$(`#${findableId}`); - - await elem.click(clickOptions); - - // In the browser context, remove the ID. - await browser.execute((elemId) => { - const clickElem = document.getElementById(elemId); - clickElem.removeAttribute('id'); - }, findableId); + return await getBlockElementById(browser, id); } /** @@ -215,7 +226,7 @@ export async function clickBlock(browser, blockId, clickOptions) { * @return A Promise that resolves when the actions are completed. */ export async function clickWorkspace(browser) { - const workspace = await browser.$('#blocklyDiv > div > svg.blocklySvg > g'); + const workspace = await browser.$('svg.blocklySvg > g'); await workspace.click(); await browser.pause(PAUSE_TIME); } @@ -253,27 +264,14 @@ export async function getCategory(browser, categoryName) { } /** - * @param browser The active WebdriverIO Browser object. - * @param categoryName The name of the toolbox category to search. - * @param n Which block to select, 0-indexed from the top of the category. - * @return A Promise that resolves to the root element of the nth - * block in the given category. - */ -export async function getNthBlockOfCategory(browser, categoryName, n) { - const category = await getCategory(browser, categoryName); - await category.click(); - const block = ( - await browser.$$(`.blocklyFlyout .blocklyBlockCanvas > .blocklyDraggable`) - )[n]; - return block; -} - -/** + * Opens the specified category, finds the first block of the given type, + * scrolls it into view, and returns a draggable element on that block. + * * @param browser The active WebdriverIO Browser object. * @param categoryName The name of the toolbox category to search. * Null if the toolbox has no categories (simple). * @param blockType The type of the block to search for. - * @return A Promise that resolves to the root element of the first + * @return A Promise that resolves to a draggable element of the first * block with the given type in the given category. */ export async function getBlockTypeFromCategory( @@ -286,13 +284,14 @@ export async function getBlockTypeFromCategory( await category.click(); } + await browser.pause(PAUSE_TIME); const id = await browser.execute((blockType) => { - return Blockly.getMainWorkspace() - .getFlyout() - .getWorkspace() - .getBlocksByType(blockType)[0].id; + const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace(); + const block = ws.getBlocksByType(blockType)[0]; + ws.scrollBoundsIntoView(block.getBoundingRectangleWithoutChildren()); + return block.id; }, blockType); - return getBlockElementById(browser, id); + return getTargetableBlockElement(browser, id, true); } /** @@ -447,7 +446,16 @@ export async function switchRTL(browser) { * created block. */ export async function dragNthBlockFromFlyout(browser, categoryName, n, x, y) { - const flyoutBlock = await getNthBlockOfCategory(browser, categoryName, n); + const category = await getCategory(browser, categoryName); + await category.click(); + + await browser.pause(PAUSE_TIME); + const id = await browser.execute((n) => { + const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace(); + const block = ws.getTopBlocks(true)[n]; + return block.id; + }, n); + const flyoutBlock = await getTargetableBlockElement(browser, id, true); await flyoutBlock.dragAndDrop({x: x, y: y}); return await getSelectedBlockElement(browser); } @@ -480,6 +488,7 @@ export async function dragBlockTypeFromFlyout( type, ); await flyoutBlock.dragAndDrop({x: x, y: y}); + await browser.pause(PAUSE_TIME); return await getSelectedBlockElement(browser); } @@ -584,26 +593,3 @@ export async function getAllBlocks(browser) { })); }); } - -/** - * Find the flyout's scrollbar and scroll by the specified amount. - * This makes several assumptions: - * - A flyout with a valid scrollbar exists, is open, and is in view. - * - The workspace has a trash can, which means it has a second (hidden) flyout. - * @param browser The active WebdriverIO Browser object. - * @param xDelta How far to drag the flyout in the x direction. Positive is right. - * @param yDelta How far to drag the flyout in the y direction. Positive is down. - * @return A Promise that resolves when the actions are completed. - */ -export async function scrollFlyout(browser, xDelta, yDelta) { - // There are two flyouts on the playground workspace: one for the trash can - // and one for the toolbox. We want the second one. - // This assumes there is only one scrollbar handle in the flyout, but it could - // be either horizontal or vertical. - await browser.pause(PAUSE_TIME); - const scrollbarHandle = await browser - .$$(`.blocklyFlyoutScrollbar`)[1] - .$(`rect.blocklyScrollbarHandle`); - await scrollbarHandle.dragAndDrop({x: xDelta, y: yDelta}); - await browser.pause(PAUSE_TIME); -} diff --git a/tests/browser/test/toolbox_drag_test.mjs b/tests/browser/test/toolbox_drag_test.mjs index 742872d9339..32c20140692 100644 --- a/tests/browser/test/toolbox_drag_test.mjs +++ b/tests/browser/test/toolbox_drag_test.mjs @@ -9,11 +9,12 @@ */ import * as chai from 'chai'; +import {Key} from 'webdriverio'; import { + dragBlockTypeFromFlyout, getCategory, PAUSE_TIME, screenDirection, - scrollFlyout, testFileLocations, testSetup, } from './test_setup.mjs'; @@ -57,28 +58,29 @@ const testCategories = [ ]; /** - * Check whether an element is fully inside the bounds of the Blockly div. You can use this - * to determine whether a block on the workspace or flyout is inside the Blockly div. - * This does not check whether there are other Blockly elements (such as a toolbox or - * flyout) on top of the element. A partially visible block is considered out of bounds. + * Get the type of the nth block in the specified category. * @param browser The active WebdriverIO Browser object. - * @param element The element to look for. - * @returns A Promise resolving to true if the element is in bounds and false otherwise. + * @param categoryName The name of the category to inspect. + * @param n The index of the block to get + * @returns A Promise resolving to the type the block in the specified + * category's flyout at index i. */ -async function elementInBounds(browser, element) { - return await browser.execute((elem) => { - const rect = elem.getBoundingClientRect(); - - const blocklyDiv = document.getElementById('blocklyDiv'); - const blocklyRect = blocklyDiv.getBoundingClientRect(); +async function getNthBlockType(browser, categoryName, n) { + const category = await getCategory(browser, categoryName); + await category.click(); + await browser.pause(PAUSE_TIME); - const vertInView = - rect.top >= blocklyRect.top && rect.bottom <= blocklyRect.bottom; - const horInView = - rect.left >= blocklyRect.left && rect.right <= blocklyRect.right; + const blockType = await browser.execute((i) => { + return Blockly.getMainWorkspace() + .getFlyout() + .getWorkspace() + .getTopBlocks(false)[i].type; + }, n); - return vertInView && horInView; - }, element); + // Unicode escape to close flyout. + await browser.keys([Key.Escape]); + await browser.pause(PAUSE_TIME); + return blockType; } /** @@ -101,7 +103,7 @@ async function getBlockCount(browser, categoryName) { }); // Unicode escape to close flyout. - await browser.keys(['\uE00C']); + await browser.keys([Key.Escape]); await browser.pause(PAUSE_TIME); return blockCount; } @@ -141,18 +143,12 @@ async function openCategories(browser, categoryList, directionMultiplier) { await category.click(); if (await isBlockDisabled(browser, i)) { // Unicode escape to close flyout. - await browser.keys(['\uE00C']); + await browser.keys([Key.Escape]); await browser.pause(PAUSE_TIME); continue; } - const flyoutBlock = await browser.$( - `.blocklyFlyout .blocklyBlockCanvas > g:nth-child(${3 + i * 2})`, - ); - while (!(await elementInBounds(browser, flyoutBlock))) { - await scrollFlyout(browser, 0, 50); - } - - await flyoutBlock.dragAndDrop({x: directionMultiplier * 50, y: 0}); + const blockType = await getNthBlockType(browser, categoryName, i); + dragBlockTypeFromFlyout(browser, categoryName, blockType, 50, 20); await browser.pause(PAUSE_TIME); // Should be one top level block on the workspace. const topBlockCount = await browser.execute(() => { @@ -178,7 +174,9 @@ async function openCategories(browser, categoryList, directionMultiplier) { chai.assert.equal(failureCount, 0); } -suite('Open toolbox categories', function () { +// TODO (#9217) These take too long to run and are very flakey. Need to find a +// better way to test whatever this is trying to test. +suite.skip('Open toolbox categories', function () { this.timeout(0); test('opening every toolbox category in the category toolbox in LTR', async function () { diff --git a/tests/browser/test/workspace_comment_test.mjs b/tests/browser/test/workspace_comment_test.mjs index 516523276f7..db42f30991a 100644 --- a/tests/browser/test/workspace_comment_test.mjs +++ b/tests/browser/test/workspace_comment_test.mjs @@ -206,13 +206,13 @@ suite('Workspace comments', function () { '.blocklyComment .blocklyResizeHandle', ); await resizeHandle.dragAndDrop(delta); - - chai.assert.deepEqual( - await getCommentSize(this.browser, commentId), - { - width: origSize.width + delta.x, - height: origSize.height + delta.y, - }, + const newSize = await getCommentSize(this.browser, commentId); + chai.assert.isTrue( + Math.abs(newSize.width - (origSize.width + delta.x)) < 1, + 'Expected the comment model size to match the resized size', + ); + chai.assert.isTrue( + Math.abs(newSize.height - (origSize.height + delta.y)) < 1, 'Expected the comment model size to match the resized size', ); }); diff --git a/tests/mocha/block_test.js b/tests/mocha/block_test.js index eda2d82a56d..62c61ce004c 100644 --- a/tests/mocha/block_test.js +++ b/tests/mocha/block_test.js @@ -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', {}); diff --git a/tests/mocha/clipboard_test.js b/tests/mocha/clipboard_test.js index 0f2d067708a..85cdd229777 100644 --- a/tests/mocha/clipboard_test.js +++ b/tests/mocha/clipboard_test.js @@ -76,7 +76,7 @@ suite('Clipboard', function () { await mutatorIcon.setBubbleVisible(true); const mutatorWorkspace = mutatorIcon.getWorkspace(); const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0]; - assert.notEqual(elseIf, undefined); + assert.isDefined(elseIf); assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2); assert.lengthOf(this.workspace.getAllBlocks(), 1); const data = elseIf.toCopyData(); @@ -85,6 +85,34 @@ suite('Clipboard', function () { assert.lengthOf(this.workspace.getAllBlocks(), 1); }); + test('pasting into a mutator flyout pastes into the mutator workspace', async function () { + const block = Blockly.serialization.blocks.append( + { + 'type': 'controls_if', + 'id': 'blockId', + 'extraState': { + 'elseIfCount': 1, + }, + }, + this.workspace, + ); + const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR); + await mutatorIcon.setBubbleVisible(true); + const mutatorWorkspace = mutatorIcon.getWorkspace(); + const mutatorFlyoutWorkspace = mutatorWorkspace + .getFlyout() + .getWorkspace(); + const elseIf = + mutatorFlyoutWorkspace.getBlocksByType('controls_if_elseif')[0]; + assert.isDefined(elseIf); + assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2); + assert.lengthOf(this.workspace.getAllBlocks(), 1); + const data = elseIf.toCopyData(); + Blockly.clipboard.paste(data, mutatorFlyoutWorkspace); + assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3); + assert.lengthOf(this.workspace.getAllBlocks(), 1); + }); + suite('pasted blocks are placed in unambiguous locations', function () { test('pasted blocks are bumped to not overlap', function () { const block = Blockly.serialization.blocks.append( @@ -139,8 +167,7 @@ suite('Clipboard', function () { }); suite('pasting comments', function () { - // TODO: Reenable test when we readd copy-paste. - test.skip('pasted comments are bumped to not overlap', function () { + test('pasted comments are bumped to not overlap', function () { Blockly.Xml.domToWorkspace( Blockly.utils.xml.textToDom( '', @@ -153,7 +180,7 @@ suite('Clipboard', function () { const newComment = Blockly.clipboard.paste(data, this.workspace); assert.deepEqual( newComment.getRelativeToSurfaceXY(), - new Blockly.utils.Coordinate(60, 60), + new Blockly.utils.Coordinate(40, 40), ); }); }); diff --git a/tests/mocha/connection_checker_test.js b/tests/mocha/connection_checker_test.js index f353a2b77c2..fee2966d766 100644 --- a/tests/mocha/connection_checker_test.js +++ b/tests/mocha/connection_checker_test.js @@ -29,7 +29,10 @@ suite('Connection checker', function () { } test('Target Null', function () { - const connection = new Blockly.Connection({}, ConnectionType.INPUT_VALUE); + const connection = new Blockly.Connection( + {id: 'test'}, + ConnectionType.INPUT_VALUE, + ); assertReasonHelper( this.checker, connection, @@ -38,7 +41,7 @@ suite('Connection checker', function () { ); }); test('Target Self', function () { - const block = {workspace: 1}; + const block = {id: 'test', workspace: 1}; const connection1 = new Blockly.Connection( block, ConnectionType.INPUT_VALUE, @@ -57,11 +60,11 @@ suite('Connection checker', function () { }); test('Different Workspaces', function () { const connection1 = new Blockly.Connection( - {workspace: 1}, + {id: 'test1', workspace: 1}, ConnectionType.INPUT_VALUE, ); const connection2 = new Blockly.Connection( - {workspace: 2}, + {id: 'test2', workspace: 2}, ConnectionType.OUTPUT_VALUE, ); @@ -76,10 +79,10 @@ suite('Connection checker', function () { setup(function () { // We have to declare each separately so that the connections belong // on different blocks. - const prevBlock = {isShadow: function () {}}; - const nextBlock = {isShadow: function () {}}; - const outBlock = {isShadow: function () {}}; - const inBlock = {isShadow: function () {}}; + const prevBlock = {id: 'test1', isShadow: function () {}}; + const nextBlock = {id: 'test2', isShadow: function () {}}; + const outBlock = {id: 'test3', isShadow: function () {}}; + const inBlock = {id: 'test4', isShadow: function () {}}; this.previous = new Blockly.Connection( prevBlock, ConnectionType.PREVIOUS_STATEMENT, @@ -197,11 +200,13 @@ suite('Connection checker', function () { suite('Shadows', function () { test('Previous Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return true; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return false; }, @@ -224,11 +229,13 @@ suite('Connection checker', function () { }); test('Next Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return false; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -251,11 +258,13 @@ suite('Connection checker', function () { }); test('Prev and Next Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return true; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -278,11 +287,13 @@ suite('Connection checker', function () { }); test('Output Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return true; }, }; const inBlock = { + id: 'test2', isShadow: function () { return false; }, @@ -305,11 +316,13 @@ suite('Connection checker', function () { }); test('Input Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return false; }, }; const inBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -332,11 +345,13 @@ suite('Connection checker', function () { }); test('Output and Input Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return true; }, }; const inBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -373,9 +388,11 @@ suite('Connection checker', function () { }; test('Output connected, adding previous', function () { const outBlock = { + id: 'test1', isShadow: function () {}, }; const inBlock = { + id: 'test2', isShadow: function () {}, }; const outCon = new Blockly.Connection( @@ -394,6 +411,7 @@ suite('Connection checker', function () { ConnectionType.PREVIOUS_STATEMENT, ); const nextBlock = { + id: 'test3', isShadow: function () {}, }; const nextCon = new Blockly.Connection( @@ -410,9 +428,11 @@ suite('Connection checker', function () { }); test('Previous connected, adding output', function () { const prevBlock = { + id: 'test1', isShadow: function () {}, }; const nextBlock = { + id: 'test2', isShadow: function () {}, }; const prevCon = new Blockly.Connection( @@ -431,6 +451,7 @@ suite('Connection checker', function () { ConnectionType.OUTPUT_VALUE, ); const inBlock = { + id: 'test3', isShadow: function () {}, }; const inCon = new Blockly.Connection( @@ -449,8 +470,14 @@ suite('Connection checker', function () { }); suite('Check Types', function () { setup(function () { - this.con1 = new Blockly.Connection({}, ConnectionType.PREVIOUS_STATEMENT); - this.con2 = new Blockly.Connection({}, ConnectionType.NEXT_STATEMENT); + this.con1 = new Blockly.Connection( + {id: 'test1'}, + ConnectionType.PREVIOUS_STATEMENT, + ); + this.con2 = new Blockly.Connection( + {id: 'test2'}, + ConnectionType.NEXT_STATEMENT, + ); }); function assertCheckTypes(checker, one, two) { assert.isTrue(checker.doTypeChecks(one, two)); diff --git a/tests/mocha/connection_db_test.js b/tests/mocha/connection_db_test.js index e7f397d545f..04f685124ca 100644 --- a/tests/mocha/connection_db_test.js +++ b/tests/mocha/connection_db_test.js @@ -5,6 +5,7 @@ */ import {ConnectionType} from '../../build/src/core/connection_type.js'; +import * as idGenerator from '../../build/src/core/utils/idgenerator.js'; import {assert} from '../../node_modules/chai/chai.js'; import { sharedTestSetup, @@ -31,7 +32,7 @@ suite('Connection Database', function () { }; workspace.connectionDBList[type] = opt_database || this.database; const connection = new Blockly.RenderedConnection( - {workspace: workspace}, + {id: idGenerator.getNextUniqueId(), workspace: workspace}, type, ); connection.x = x; diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index 1d283f331a6..6f841ae09c6 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -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); diff --git a/tests/mocha/event_test.js b/tests/mocha/event_test.js index 00d704ff052..7423f22f74b 100644 --- a/tests/mocha/event_test.js +++ b/tests/mocha/event_test.js @@ -355,6 +355,7 @@ suite('Events', function () { suite('With variable getter blocks', function () { setup(function () { + this.TEST_BLOCK_ID = 'test_block_id'; this.genUidStub = createGenUidStubWithReturns([ this.TEST_BLOCK_ID, 'test_var_id', diff --git a/tests/mocha/field_textinput_test.js b/tests/mocha/field_textinput_test.js index 82c1a645e6d..7dc105f72f0 100644 --- a/tests/mocha/field_textinput_test.js +++ b/tests/mocha/field_textinput_test.js @@ -294,4 +294,300 @@ suite('Text Input Fields', function () { this.assertValue('test text'); }); }); + + suite('Use editor', function () { + setup(function () { + this.blockJson = { + 'type': 'math_arithmetic', + 'id': 'test_arithmetic_block', + 'fields': { + 'OP': 'ADD', + }, + 'inputs': { + 'A': { + 'shadow': { + 'type': 'math_number', + 'id': 'left_input_block', + 'name': 'test_name', + 'fields': { + 'NUM': 1, + }, + }, + }, + 'B': { + 'shadow': { + 'type': 'math_number', + 'id': 'right_input_block', + 'fields': { + 'NUM': 2, + }, + }, + }, + }, + }; + + this.getFieldFromShadowBlock = function (shadowBlock) { + return shadowBlock.getFields().next().value; + }; + + this.simulateTypingIntoInput = (inputElem, newText) => { + // Typing into an input field changes its value directly and then fires + // an InputEvent (which FieldInput relies on to automatically + // synchronize its state). + inputElem.value = newText; + inputElem.dispatchEvent(new InputEvent('input')); + }; + }); + + // The block being tested doesn't use full-block fields in Geras. + suite('Geras theme', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + Blockly.serialization.blocks.append(this.blockJson, this.workspace); + + // The workspace actually needs to be visible for focus. + document.getElementById('blocklyDiv').style.visibility = 'visible'; + }); + teardown(function () { + document.getElementById('blocklyDiv').style.visibility = 'hidden'; + workspaceTeardown.call(this, this.workspace); + }); + + test('No editor open by default', function () { + // The editor is only opened if its indicated that it should be open. + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Type in editor with escape does not change field value', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, 'updated value'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + }), + ); + + // 'Escape' will avoid saving the edited field value and close the editor. + assert.equal(field.getValue(), 1); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Type in editor with enter changes field value', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // 'Enter' will save the edited result and close the editor. + assert.equal(field.getValue(), 10); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Not finishing editing does not return ephemeral focus', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + Blockly.getFocusManager().focusNode(field); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + + // If the editor doesn't restore focus then the current focused element is + // still the editor. + assert.strictEqual(document.activeElement, fieldEditor); + }); + + test('Finishing editing returns ephemeral focus', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + Blockly.getFocusManager().focusNode(field); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + }), + ); + + // Verify that exiting the editor restores focus back to the field. + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), field); + assert.strictEqual(document.activeElement, field.getFocusableElement()); + }); + + test('Opening an editor, tabbing, then editing changes the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that only the right field changed (due to the tab). + assert.equal(leftField.getValue(), 1); + assert.equal(rightField.getValue(), 15); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Opening an editor, tabbing, then editing changes focus to the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that the tab causes focus to change to the right field. + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + rightField, + ); + assert.strictEqual( + document.activeElement, + rightField.getFocusableElement(), + ); + }); + }); + + // The block being tested uses full-block fields in Zelos. + suite('Zelos theme', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'zelos', + }); + Blockly.serialization.blocks.append(this.blockJson, this.workspace); + + // The workspace actually needs to be visible for focus. + document.getElementById('blocklyDiv').style.visibility = 'visible'; + }); + teardown(function () { + document.getElementById('blocklyDiv').style.visibility = 'hidden'; + workspaceTeardown.call(this, this.workspace); + }); + + test('Opening an editor, tabbing, then editing full block field changes the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that only the right field changed (due to the tab). + assert.equal(leftField.getValue(), 1); + assert.equal(rightField.getValue(), 15); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Opening an editor, tabbing, then editing full block field changes focus to the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + Blockly.getFocusManager().focusNode(leftInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that the tab causes focus to change to the right field block. + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + rightInputBlock, + ); + assert.strictEqual( + document.activeElement, + rightInputBlock.getFocusableElement(), + ); + }); + }); + }); }); diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index 3a1fc98a7e5..26dcb8dbe68 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -249,6 +249,54 @@ suite('FocusManager', function () { // The second register should not fail since the tree was previously unregistered. }); + test('for tree with missing ID throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.removeAttribute('id'); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + + test('for tree with null ID throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.setAttribute('id', null); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + + test('for tree with empty throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.setAttribute('id', ''); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + test('for unmanaged tree does not overwrite tab index', function () { this.focusManager.registerTree(this.testFocusableTree1, false); diff --git a/tests/mocha/focusable_tree_traverser_test.js b/tests/mocha/focusable_tree_traverser_test.js index 66cc598ccf5..0f88e1106f9 100644 --- a/tests/mocha/focusable_tree_traverser_test.js +++ b/tests/mocha/focusable_tree_traverser_test.js @@ -348,6 +348,80 @@ suite('FocusableTreeTraverser', function () { }); suite('findFocusableNodeFor()', function () { + test('for element without ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // Normally it's not valid to miss an ID, but it can realistically happen. + rootElem.removeAttribute('id'); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with null ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // Normally it's not valid to miss an ID, but it can realistically happen. + rootElem.setAttribute('id', null); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with null ID string returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // This is a quirky version of the null variety above that's actually + // functionallity equivalent (since 'null' is converted to a string). + rootElem.setAttribute('id', 'null'); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with empty ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // An empty ID is invalid since it will potentially conflict with other + // elements, and element IDs must be unique for focus management. + rootElem.setAttribute('id', ''); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + test('for root element returns root', function () { const tree = this.testFocusableTree1; const rootNode = tree.getRootFocusableNode(); diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index 622df9efcf9..d96ddbfeadc 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -5,6 +5,7 @@ */ import * as Blockly from '../../build/src/core/blockly.js'; +import {assert} from '../../node_modules/chai/chai.js'; import {defineStackBlock} from './test_helpers/block_definitions.js'; import { sharedTestSetup, @@ -47,6 +48,16 @@ suite('Keyboard Shortcut Items', function () { .returns(block.nextConnection); } + /** + * Creates a workspace comment and set it as the focused node. + * @param {Blockly.Workspace} workspace The workspace to create a new comment on. + */ + function setSelectedComment(workspace) { + const comment = workspace.newComment(); + sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(comment); + return comment; + } + /** * Creates a test for not running keyDown events when the workspace is in read only mode. * @param {Object} keyEvent Mocked key down event. Use createKeyDownEvent. @@ -173,12 +184,17 @@ suite('Keyboard Shortcut Items', function () { }); }); }); - // Do not copy a block if a workspace is in readonly mode. - suite('Not called when readOnly is true', function () { + // Allow copying a block if a workspace is in readonly mode. + suite('Called when readOnly is true', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; const keyEvent = testCase[1]; - runReadOnlyTest(keyEvent, testCaseName); + test(testCaseName, function () { + this.workspace.setIsReadOnly(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.hideChaffSpy); + }); }); }); // Do not copy a block if a drag is in progress. @@ -236,6 +252,165 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.notCalled(this.copySpy); sinon.assert.notCalled(this.hideChaffSpy); }); + // Copy a comment. + test('Workspace comment', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + Blockly.getFocusManager().getFocusedNode.restore(); + this.comment = setSelectedComment(this.workspace); + this.copySpy = sinon.spy(this.comment, 'toCopyData'); + + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.hideChaffSpy); + }); + }); + }); + }); + + suite('Cut', function () { + setup(function () { + this.block = setSelectedBlock(this.workspace); + this.copySpy = sinon.spy(this.block, 'toCopyData'); + this.disposeSpy = sinon.spy(this.block, 'dispose'); + this.hideChaffSpy = sinon.spy( + Blockly.WorkspaceSvg.prototype, + 'hideChaff', + ); + }); + const testCases = [ + [ + 'Control X', + createKeyDownEvent(Blockly.utils.KeyCodes.X, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ], + [ + 'Meta X', + createKeyDownEvent(Blockly.utils.KeyCodes.X, [ + Blockly.utils.KeyCodes.META, + ]), + ], + ]; + // Cut a block. + suite('Simple', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.disposeSpy); + sinon.assert.calledOnce(this.hideChaffSpy); + }); + }); + }); + // Do not cut a block if a workspace is in readonly mode. + suite('Not called when readOnly is true', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + this.workspace.setIsReadOnly(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + }); + // Do not cut a block if a drag is in progress. + suite('Drag in progress', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + sinon.stub(this.workspace, 'isDragging').returns(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + }); + // Do not cut a block if is is not deletable. + suite('Block is not deletable', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + sinon + .stub(Blockly.common.getSelected(), 'isOwnDeletable') + .returns(false); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + }); + // Do not cut a block if it is not movable. + suite('Block is not movable', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + sinon + .stub(Blockly.common.getSelected(), 'isOwnMovable') + .returns(false); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + }); + test('Not called when connection is focused', function () { + // Restore the stub behavior called during setup + Blockly.getFocusManager().getFocusedNode.restore(); + + setSelectedConnection(this.workspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [ + Blockly.utils.KeyCodes.CTRL, + ]); + this.injectionDiv.dispatchEvent(event); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + + // Cut a comment. + suite('Workspace comment', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + Blockly.getFocusManager().getFocusedNode.restore(); + this.comment = setSelectedComment(this.workspace); + this.copySpy = sinon.spy(this.comment, 'toCopyData'); + this.disposeSpy = sinon.spy(this.comment, 'dispose'); + + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.disposeSpy); + }); + }); + }); + }); + + suite('Paste', function () { + test('Disabled when nothing has been copied', function () { + const pasteShortcut = + Blockly.ShortcutRegistry.registry.getRegistry()[ + Blockly.ShortcutItems.names.PASTE + ]; + Blockly.clipboard.setLastCopiedData(undefined); + + const isPasteEnabled = pasteShortcut.preconditionFn(); + assert.isFalse(isPasteEnabled); + }); }); suite('Undo', function () { @@ -273,13 +448,13 @@ suite('Keyboard Shortcut Items', function () { }); }); }); - // Do not undo if a gesture is in progress. - suite('Gesture in progress', function () { + // Do not undo if a drag is in progress. + suite('Drag in progress', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; const keyEvent = testCase[1]; test(testCaseName, function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(keyEvent); sinon.assert.notCalled(this.undoSpy); sinon.assert.notCalled(this.hideChaffSpy); @@ -333,13 +508,13 @@ suite('Keyboard Shortcut Items', function () { }); }); }); - // Do not undo if a gesture is in progress. - suite('Gesture in progress', function () { + // Do not redo if a drag is in progress. + suite('Drag in progress', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; const keyEvent = testCase[1]; test(testCaseName, function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(keyEvent); sinon.assert.notCalled(this.redoSpy); sinon.assert.notCalled(this.hideChaffSpy); @@ -373,8 +548,8 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.calledWith(this.undoSpy, true); sinon.assert.calledOnce(this.hideChaffSpy); }); - test('Not called when a gesture is in progress', function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + test('Not called when a drag is in progress', function () { + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(this.ctrlYEvent); sinon.assert.notCalled(this.undoSpy); sinon.assert.notCalled(this.hideChaffSpy); diff --git a/tests/mocha/test_helpers/icon_mocks.js b/tests/mocha/test_helpers/icon_mocks.js index 5d117c71284..0e549b9764c 100644 --- a/tests/mocha/test_helpers/icon_mocks.js +++ b/tests/mocha/test_helpers/icon_mocks.js @@ -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'); } diff --git a/tests/mocha/workspace_comment_test.js b/tests/mocha/workspace_comment_test.js index 6e3fa9607a0..3ce276e8579 100644 --- a/tests/mocha/workspace_comment_test.js +++ b/tests/mocha/workspace_comment_test.js @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {assert} from '../../node_modules/chai/chai.js'; import { assertEventFired, createChangeListenerSpy, @@ -167,5 +168,15 @@ suite('Workspace comment', function () { this.workspace.id, ); }); + + test('focuses the workspace when deleted', function () { + const comment = new Blockly.comments.RenderedWorkspaceComment( + this.workspace, + ); + Blockly.getFocusManager().focusNode(comment); + assert.equal(Blockly.getFocusManager().getFocusedNode(), comment); + comment.view.getCommentBarButtons()[1].performAction(); + assert.equal(Blockly.getFocusManager().getFocusedNode(), this.workspace); + }); }); });