mirror of
https://github.com/google/blockly.git
synced 2026-03-17 10:40:10 +01:00
release: v12.2.0
release: v12.2.0 Merge pull request #9224 from google/rc/v12.2.0
This commit is contained in:
66
.github/workflows/keyboard_plugin_test.yml
vendored
Normal file
66
.github/workflows/keyboard_plugin_test.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# Workflow for running the keyboard navigation plugin's automated tests.
|
||||
|
||||
name: Keyboard Navigation Automated Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
webdriverio_tests:
|
||||
name: WebdriverIO tests
|
||||
timeout-minutes: 10
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout core Blockly
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: core-blockly
|
||||
|
||||
- name: Checkout keyboard navigation plugin
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'google/blockly-keyboard-experimentation'
|
||||
ref: 'main'
|
||||
path: blockly-keyboard-experimentation
|
||||
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: NPM install
|
||||
run: |
|
||||
cd core-blockly
|
||||
npm install
|
||||
cd ..
|
||||
cd blockly-keyboard-experimentation
|
||||
npm install
|
||||
cd ..
|
||||
|
||||
- name: Link latest core develop with plugin
|
||||
run: |
|
||||
cd core-blockly
|
||||
npm run package
|
||||
cd dist
|
||||
npm link
|
||||
cd ../../blockly-keyboard-experimentation
|
||||
npm link blockly
|
||||
cd ..
|
||||
|
||||
- name: Run keyboard navigation plugin tests
|
||||
run: |
|
||||
cd blockly-keyboard-experimentation
|
||||
npm run test
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
|
||||
import * as registry from './clipboard/registry.js';
|
||||
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
|
||||
import {isSelectable} from './interfaces/i_selectable.js';
|
||||
import * as globalRegistry from './registry.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import {WorkspaceSvg} from './workspace_svg.js';
|
||||
@@ -18,18 +19,119 @@ let stashedCopyData: ICopyData | null = null;
|
||||
|
||||
let stashedWorkspace: WorkspaceSvg | null = null;
|
||||
|
||||
let stashedCoordinates: Coordinate | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Private version of copy for stubbing in tests.
|
||||
* Copy a copyable item, and record its data and the workspace it was
|
||||
* copied from.
|
||||
*
|
||||
* This function does not perform any checks to ensure the copy
|
||||
* should be allowed, e.g. to ensure the block is deletable. Such
|
||||
* checks should be done before calling this function.
|
||||
*
|
||||
* Note that if the copyable item is not an `ISelectable` or its
|
||||
* `workspace` property is not a `WorkspaceSvg`, the copy will be
|
||||
* successful, but there will be no saved workspace data. This will
|
||||
* impact the ability to paste the data unless you explictily pass
|
||||
* a workspace into the paste method.
|
||||
*
|
||||
* @param toCopy item to copy.
|
||||
* @param location location to save as a potential paste location.
|
||||
* @returns the copied data if copy was successful, otherwise null.
|
||||
*/
|
||||
function copyInternal<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
|
||||
export function copy<T extends ICopyData>(
|
||||
toCopy: ICopyable<T>,
|
||||
location?: Coordinate,
|
||||
): T | null {
|
||||
const data = toCopy.toCopyData();
|
||||
stashedCopyData = data;
|
||||
stashedWorkspace = (toCopy as any).workspace ?? null;
|
||||
if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) {
|
||||
stashedWorkspace = toCopy.workspace;
|
||||
} else {
|
||||
stashedWorkspace = null;
|
||||
}
|
||||
|
||||
stashedCoordinates = location;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste a pasteable element into the workspace.
|
||||
* Gets the copy data for the last item copied. This is useful if you
|
||||
* are implementing custom copy/paste behavior. If you want the default
|
||||
* behavior, just use the copy and paste methods directly.
|
||||
*
|
||||
* @returns copy data for the last item copied, or null if none set.
|
||||
*/
|
||||
export function getLastCopiedData() {
|
||||
return stashedCopyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last copied item. You should call this method if you implement
|
||||
* custom copy behavior, so that other callers are working with the correct
|
||||
* data. This method is called automatically if you use the built-in copy
|
||||
* method.
|
||||
*
|
||||
* @param copyData copy data for the last item copied.
|
||||
*/
|
||||
export function setLastCopiedData(copyData: ICopyData) {
|
||||
stashedCopyData = copyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the workspace that was last copied from. This is useful if you
|
||||
* are implementing custom copy/paste behavior and want to paste on the
|
||||
* same workspace that was copied from. If you want the default behavior,
|
||||
* just use the copy and paste methods directly.
|
||||
*
|
||||
* @returns workspace that was last copied from, or null if none set.
|
||||
*/
|
||||
export function getLastCopiedWorkspace() {
|
||||
return stashedWorkspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the workspace that was last copied from. You should call this method
|
||||
* if you implement custom copy behavior, so that other callers are working
|
||||
* with the correct data. This method is called automatically if you use the
|
||||
* built-in copy method.
|
||||
*
|
||||
* @param workspace workspace that was last copied from.
|
||||
*/
|
||||
export function setLastCopiedWorkspace(workspace: WorkspaceSvg) {
|
||||
stashedWorkspace = workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the location that was last copied from. This is useful if you
|
||||
* are implementing custom copy/paste behavior. If you want the
|
||||
* default behavior, just use the copy and paste methods directly.
|
||||
*
|
||||
* @returns last saved location, or null if none set.
|
||||
*/
|
||||
export function getLastCopiedLocation() {
|
||||
return stashedCoordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the location that was last copied from. You should call this method
|
||||
* if you implement custom copy behavior, so that other callers are working
|
||||
* with the correct data. This method is called automatically if you use the
|
||||
* built-in copy method.
|
||||
*
|
||||
* @param location last saved location, which can be used to paste at.
|
||||
*/
|
||||
export function setLastCopiedLocation(location: Coordinate) {
|
||||
stashedCoordinates = location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste a pasteable element into the given workspace.
|
||||
*
|
||||
* This function does not perform any checks to ensure the paste
|
||||
* is allowed, e.g. that the workspace is rendered or the block
|
||||
* is pasteable. Such checks should be done before calling this
|
||||
* function.
|
||||
*
|
||||
* @param copyData The data to paste into the workspace.
|
||||
* @param workspace The workspace to paste the data into.
|
||||
@@ -43,7 +145,7 @@ export function paste<T extends ICopyData>(
|
||||
): ICopyable<T> | null;
|
||||
|
||||
/**
|
||||
* Pastes the last copied ICopyable into the workspace.
|
||||
* Pastes the last copied ICopyable into the last copied-from workspace.
|
||||
*
|
||||
* @returns the pasted thing if the paste was successful, null otherwise.
|
||||
*/
|
||||
@@ -65,7 +167,7 @@ export function paste<T extends ICopyData>(
|
||||
): ICopyable<ICopyData> | null {
|
||||
if (!copyData || !workspace) {
|
||||
if (!stashedCopyData || !stashedWorkspace) return null;
|
||||
return pasteFromData(stashedCopyData, stashedWorkspace);
|
||||
return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates);
|
||||
}
|
||||
return pasteFromData(copyData, workspace, coordinate);
|
||||
}
|
||||
@@ -85,31 +187,11 @@ function pasteFromData<T extends ICopyData>(
|
||||
): ICopyable<T> | null {
|
||||
workspace = workspace.isMutator
|
||||
? workspace
|
||||
: (workspace.getRootWorkspace() ?? workspace);
|
||||
: // Use the parent workspace if it exists (e.g. for pasting into flyouts)
|
||||
(workspace.options.parentWorkspace ?? workspace);
|
||||
return (globalRegistry
|
||||
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
|
||||
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private version of duplicate for stubbing in tests.
|
||||
*/
|
||||
function duplicateInternal<
|
||||
U extends ICopyData,
|
||||
T extends ICopyable<U> & IHasWorkspace,
|
||||
>(toDuplicate: T): T | null {
|
||||
const data = toDuplicate.toCopyData();
|
||||
if (!data) return null;
|
||||
return paste(data, toDuplicate.workspace) as T;
|
||||
}
|
||||
|
||||
interface IHasWorkspace {
|
||||
workspace: WorkspaceSvg;
|
||||
}
|
||||
|
||||
export const TEST_ONLY = {
|
||||
duplicateInternal,
|
||||
copyInternal,
|
||||
};
|
||||
|
||||
export {BlockCopyData, BlockPaster, registry};
|
||||
|
||||
@@ -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';
|
||||
|
||||
101
core/comments/collapse_comment_bar_button.ts
Normal file
101
core/comments/collapse_comment_bar_button.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {CommentBarButton} from './comment_bar_button.js';
|
||||
|
||||
/**
|
||||
* Magic string appended to the comment ID to create a unique ID for this button.
|
||||
*/
|
||||
export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER =
|
||||
'_collapse_bar_button';
|
||||
|
||||
/**
|
||||
* Button that toggles the collapsed state of a comment.
|
||||
*/
|
||||
export class CollapseCommentBarButton extends CommentBarButton {
|
||||
/**
|
||||
* Opaque ID used to unbind event handlers during disposal.
|
||||
*/
|
||||
private readonly bindId: browserEvents.Data;
|
||||
|
||||
/**
|
||||
* SVG image displayed on this button.
|
||||
*/
|
||||
protected override readonly icon: SVGImageElement;
|
||||
|
||||
/**
|
||||
* Creates a new CollapseCommentBarButton instance.
|
||||
*
|
||||
* @param id The ID of this button's parent comment.
|
||||
* @param workspace The workspace this button's parent comment is displayed on.
|
||||
* @param container An SVG group that this button should be a child of.
|
||||
*/
|
||||
constructor(
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
) {
|
||||
super(id, workspace, container);
|
||||
|
||||
this.icon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
{
|
||||
'class': 'blocklyFoldoutIcon',
|
||||
'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`,
|
||||
'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
|
||||
},
|
||||
this.container,
|
||||
);
|
||||
this.bindId = browserEvents.conditionalBind(
|
||||
this.icon,
|
||||
'pointerdown',
|
||||
this,
|
||||
this.performAction.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of this button.
|
||||
*/
|
||||
dispose() {
|
||||
browserEvents.unbind(this.bindId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the positioning of this button within its container.
|
||||
*/
|
||||
override reposition() {
|
||||
const margin = this.getMargin();
|
||||
this.icon.setAttribute('y', `${margin}`);
|
||||
this.icon.setAttribute('x', `${margin}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the collapsed state of the parent comment.
|
||||
*
|
||||
* @param e The event that triggered this action.
|
||||
*/
|
||||
override performAction(e?: Event) {
|
||||
touch.clearTouchIdentifier();
|
||||
|
||||
const comment = this.getParentComment();
|
||||
comment.view.bringToFront();
|
||||
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
comment.setCollapsed(!comment.isCollapsed());
|
||||
this.workspace.hideChaff();
|
||||
|
||||
e?.stopPropagation();
|
||||
}
|
||||
}
|
||||
105
core/comments/comment_bar_button.ts
Normal file
105
core/comments/comment_bar_button.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {Rect} from '../utils/rect.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
|
||||
|
||||
/**
|
||||
* Button displayed on a comment's top bar.
|
||||
*/
|
||||
export abstract class CommentBarButton implements IFocusableNode {
|
||||
/**
|
||||
* SVG image displayed on this button.
|
||||
*/
|
||||
protected abstract readonly icon: SVGImageElement;
|
||||
|
||||
/**
|
||||
* Creates a new CommentBarButton instance.
|
||||
*
|
||||
* @param id The ID of this button's parent comment.
|
||||
* @param workspace The workspace this button's parent comment is on.
|
||||
* @param container An SVG group that this button should be a child of.
|
||||
*/
|
||||
constructor(
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns whether or not this button is currently visible.
|
||||
*/
|
||||
isVisible(): boolean {
|
||||
return this.icon.checkVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent comment of this comment bar button.
|
||||
*/
|
||||
getParentComment(): RenderedWorkspaceComment {
|
||||
const comment = this.workspace.getCommentById(this.id);
|
||||
if (!comment) {
|
||||
throw new Error(
|
||||
`Comment bar button ${this.id} has no corresponding comment`,
|
||||
);
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
/** Adjusts the position of this button within its parent container. */
|
||||
abstract reposition(): void;
|
||||
|
||||
/** Perform the action this button should take when it is acted on. */
|
||||
abstract performAction(e?: Event): void;
|
||||
|
||||
/**
|
||||
* Returns the dimensions of this button in workspace coordinates.
|
||||
*
|
||||
* @param includeMargin True to include the margin when calculating the size.
|
||||
* @returns The size of this button.
|
||||
*/
|
||||
getSize(includeMargin = false): Rect {
|
||||
const bounds = this.icon.getBBox();
|
||||
const rect = Rect.from(bounds);
|
||||
if (includeMargin) {
|
||||
const margin = this.getMargin();
|
||||
rect.left -= margin;
|
||||
rect.top -= margin;
|
||||
rect.bottom += margin;
|
||||
rect.right += margin;
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
|
||||
/** Returns the margin in workspace coordinates surrounding this button. */
|
||||
getMargin(): number {
|
||||
return (this.container.getBBox().height - this.icon.getBBox().height) / 2;
|
||||
}
|
||||
|
||||
/** Returns a DOM element representing this button that can receive focus. */
|
||||
getFocusableElement() {
|
||||
return this.icon;
|
||||
}
|
||||
|
||||
/** Returns the workspace this button is a child of. */
|
||||
getFocusableTree() {
|
||||
return this.workspace;
|
||||
}
|
||||
|
||||
/** Called when this button's focusable DOM element gains focus. */
|
||||
onNodeFocus() {}
|
||||
|
||||
/** Called when this button's focusable DOM element loses focus. */
|
||||
onNodeBlur() {}
|
||||
|
||||
/** Returns whether this button can be focused. True if it is visible. */
|
||||
canBeFocused() {
|
||||
return this.isVisible();
|
||||
}
|
||||
}
|
||||
191
core/comments/comment_editor.ts
Normal file
191
core/comments/comment_editor.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2024 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Size} from '../utils/size.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
/**
|
||||
* String added to the ID of a workspace comment to identify
|
||||
* the focusable node for the comment editor.
|
||||
*/
|
||||
export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_';
|
||||
|
||||
/** The part of a comment that can be typed into. */
|
||||
export class CommentEditor implements IFocusableNode {
|
||||
id?: string;
|
||||
/** The foreignObject containing the HTML text area. */
|
||||
private foreignObject: SVGForeignObjectElement;
|
||||
|
||||
/** The text area where the user can type. */
|
||||
private textArea: HTMLTextAreaElement;
|
||||
|
||||
/** Listeners for changes to text. */
|
||||
private textChangeListeners: Array<
|
||||
(oldText: string, newText: string) => void
|
||||
> = [];
|
||||
|
||||
/** The current text of the comment. Updates on text area change. */
|
||||
private text: string = '';
|
||||
|
||||
constructor(
|
||||
public workspace: WorkspaceSvg,
|
||||
commentId?: string,
|
||||
private onFinishEditing?: () => void,
|
||||
) {
|
||||
this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, {
|
||||
'class': 'blocklyCommentForeignObject',
|
||||
});
|
||||
const body = document.createElementNS(dom.HTML_NS, 'body');
|
||||
body.setAttribute('xmlns', dom.HTML_NS);
|
||||
body.className = 'blocklyMinimalBody';
|
||||
this.textArea = document.createElementNS(
|
||||
dom.HTML_NS,
|
||||
'textarea',
|
||||
) as HTMLTextAreaElement;
|
||||
this.textArea.setAttribute('tabindex', '-1');
|
||||
dom.addClass(this.textArea, 'blocklyCommentText');
|
||||
dom.addClass(this.textArea, 'blocklyTextarea');
|
||||
dom.addClass(this.textArea, 'blocklyText');
|
||||
body.appendChild(this.textArea);
|
||||
this.foreignObject.appendChild(body);
|
||||
|
||||
if (commentId) {
|
||||
this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER;
|
||||
this.textArea.setAttribute('id', this.id);
|
||||
}
|
||||
|
||||
// Register browser event listeners for the user typing in the textarea.
|
||||
browserEvents.conditionalBind(
|
||||
this.textArea,
|
||||
'change',
|
||||
this,
|
||||
this.onTextChange,
|
||||
);
|
||||
|
||||
// Register listener for pointerdown to focus the textarea.
|
||||
browserEvents.conditionalBind(
|
||||
this.textArea,
|
||||
'pointerdown',
|
||||
this,
|
||||
(e: PointerEvent) => {
|
||||
// don't allow this event to bubble up
|
||||
// and steal focus away from the editor/comment.
|
||||
e.stopPropagation();
|
||||
getFocusManager().focusNode(this);
|
||||
touch.clearTouchIdentifier();
|
||||
},
|
||||
);
|
||||
|
||||
// Register listener for keydown events that would finish editing.
|
||||
browserEvents.conditionalBind(
|
||||
this.textArea,
|
||||
'keydown',
|
||||
this,
|
||||
this.handleKeyDown,
|
||||
);
|
||||
}
|
||||
|
||||
/** Gets the dom structure for this comment editor. */
|
||||
getDom(): SVGForeignObjectElement {
|
||||
return this.foreignObject;
|
||||
}
|
||||
|
||||
/** Gets the current text of the comment. */
|
||||
getText(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
/** Sets the current text of the comment and fires change listeners. */
|
||||
setText(text: string) {
|
||||
this.textArea.value = text;
|
||||
this.onTextChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers listeners when the text of the comment changes, either
|
||||
* programmatically or manually by the user.
|
||||
*/
|
||||
private onTextChange() {
|
||||
const oldText = this.text;
|
||||
this.text = this.textArea.value;
|
||||
// Loop through listeners backwards in case they remove themselves.
|
||||
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
|
||||
this.textChangeListeners[i](oldText, this.text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do something when the user indicates they've finished editing.
|
||||
*
|
||||
* @param e Keyboard event.
|
||||
*/
|
||||
private handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
|
||||
if (this.onFinishEditing) this.onFinishEditing();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a callback that listens for text changes. */
|
||||
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
|
||||
this.textChangeListeners.push(listener);
|
||||
}
|
||||
|
||||
/** Removes the given listener from the list of text change listeners. */
|
||||
removeTextChangeListener(listener: () => void) {
|
||||
this.textChangeListeners.splice(
|
||||
this.textChangeListeners.indexOf(listener),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
/** Sets the placeholder text displayed for an empty comment. */
|
||||
setPlaceholderText(text: string) {
|
||||
this.textArea.placeholder = text;
|
||||
}
|
||||
|
||||
/** Sets whether the textarea is editable. If not, the textarea will be readonly. */
|
||||
setEditable(isEditable: boolean) {
|
||||
if (isEditable) {
|
||||
this.textArea.removeAttribute('readonly');
|
||||
} else {
|
||||
this.textArea.setAttribute('readonly', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the size of the comment editor element. */
|
||||
updateSize(size: Size, topBarSize: Size) {
|
||||
this.foreignObject.setAttribute(
|
||||
'height',
|
||||
`${size.height - topBarSize.height}`,
|
||||
);
|
||||
this.foreignObject.setAttribute('width', `${size.width}`);
|
||||
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
|
||||
if (this.workspace.RTL) {
|
||||
this.foreignObject.setAttribute('x', `${-size.width}`);
|
||||
}
|
||||
}
|
||||
|
||||
getFocusableElement(): HTMLElement | SVGElement {
|
||||
return this.textArea;
|
||||
}
|
||||
getFocusableTree(): IFocusableTree {
|
||||
return this.workspace;
|
||||
}
|
||||
onNodeFocus(): void {}
|
||||
onNodeBlur(): void {}
|
||||
canBeFocused(): boolean {
|
||||
if (this.id) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import * as css from '../css.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node';
|
||||
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
|
||||
import * as layers from '../layers.js';
|
||||
import * as touch from '../touch.js';
|
||||
@@ -15,13 +16,17 @@ import * as drag from '../utils/drag.js';
|
||||
import {Size} from '../utils/size.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {CollapseCommentBarButton} from './collapse_comment_bar_button.js';
|
||||
import {CommentBarButton} from './comment_bar_button.js';
|
||||
import {CommentEditor} from './comment_editor.js';
|
||||
import {DeleteCommentBarButton} from './delete_comment_bar_button.js';
|
||||
|
||||
export class CommentView implements IRenderedElement {
|
||||
/** The root group element of the comment view. */
|
||||
private svgRoot: SVGGElement;
|
||||
|
||||
/**
|
||||
* The svg rect element that we use to create a hightlight around the comment.
|
||||
* The SVG rect element that we use to create a highlight around the comment.
|
||||
*/
|
||||
private highlightRect: SVGRectElement;
|
||||
|
||||
@@ -31,11 +36,11 @@ export class CommentView implements IRenderedElement {
|
||||
/** The rect background for the top bar. */
|
||||
private topBarBackground: SVGRectElement;
|
||||
|
||||
/** The delete icon that goes in the top bar. */
|
||||
private deleteIcon: SVGImageElement;
|
||||
/** The delete button that goes in the top bar. */
|
||||
private deleteButton: DeleteCommentBarButton;
|
||||
|
||||
/** The foldout icon that goes in the top bar. */
|
||||
private foldoutIcon: SVGImageElement;
|
||||
/** The foldout button that goes in the top bar. */
|
||||
private foldoutButton: CollapseCommentBarButton;
|
||||
|
||||
/** The text element that goes in the top bar. */
|
||||
private textPreview: SVGTextElement;
|
||||
@@ -46,11 +51,8 @@ export class CommentView implements IRenderedElement {
|
||||
/** The resize handle element. */
|
||||
private resizeHandle: SVGImageElement;
|
||||
|
||||
/** The foreignObject containing the HTML text area. */
|
||||
private foreignObject: SVGForeignObjectElement;
|
||||
|
||||
/** The text area where the user can type. */
|
||||
private textArea: HTMLTextAreaElement;
|
||||
/** The part of the comment view that contains the textarea to edit the comment. */
|
||||
private commentEditor: CommentEditor;
|
||||
|
||||
/** The current size of the comment in workspace units. */
|
||||
private size: Size;
|
||||
@@ -64,14 +66,6 @@ export class CommentView implements IRenderedElement {
|
||||
/** The current location of the comment in workspace coordinates. */
|
||||
private location: Coordinate = new Coordinate(0, 0);
|
||||
|
||||
/** The current text of the comment. Updates on text area change. */
|
||||
private text: string = '';
|
||||
|
||||
/** Listeners for changes to text. */
|
||||
private textChangeListeners: Array<
|
||||
(oldText: string, newText: string) => void
|
||||
> = [];
|
||||
|
||||
/** Listeners for changes to size. */
|
||||
private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> =
|
||||
[];
|
||||
@@ -106,7 +100,10 @@ export class CommentView implements IRenderedElement {
|
||||
/** The default size of newly created comments. */
|
||||
static defaultCommentSize = new Size(120, 100);
|
||||
|
||||
constructor(readonly workspace: WorkspaceSvg) {
|
||||
constructor(
|
||||
readonly workspace: WorkspaceSvg,
|
||||
private commentId: string,
|
||||
) {
|
||||
this.svgRoot = dom.createSvgElement(Svg.G, {
|
||||
'class': 'blocklyComment blocklyEditable blocklyDraggable',
|
||||
});
|
||||
@@ -116,14 +113,13 @@ export class CommentView implements IRenderedElement {
|
||||
({
|
||||
topBarGroup: this.topBarGroup,
|
||||
topBarBackground: this.topBarBackground,
|
||||
deleteIcon: this.deleteIcon,
|
||||
foldoutIcon: this.foldoutIcon,
|
||||
deleteButton: this.deleteButton,
|
||||
foldoutButton: this.foldoutButton,
|
||||
textPreview: this.textPreview,
|
||||
textPreviewNode: this.textPreviewNode,
|
||||
} = this.createTopBar(this.svgRoot, workspace));
|
||||
} = this.createTopBar(this.svgRoot));
|
||||
|
||||
({foreignObject: this.foreignObject, textArea: this.textArea} =
|
||||
this.createTextArea(this.svgRoot));
|
||||
this.commentEditor = this.createTextArea();
|
||||
|
||||
this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace);
|
||||
|
||||
@@ -154,14 +150,11 @@ export class CommentView implements IRenderedElement {
|
||||
* Creates the top bar and the elements visually within it.
|
||||
* Registers event listeners.
|
||||
*/
|
||||
private createTopBar(
|
||||
svgRoot: SVGGElement,
|
||||
workspace: WorkspaceSvg,
|
||||
): {
|
||||
private createTopBar(svgRoot: SVGGElement): {
|
||||
topBarGroup: SVGGElement;
|
||||
topBarBackground: SVGRectElement;
|
||||
deleteIcon: SVGImageElement;
|
||||
foldoutIcon: SVGImageElement;
|
||||
deleteButton: DeleteCommentBarButton;
|
||||
foldoutButton: CollapseCommentBarButton;
|
||||
textPreview: SVGTextElement;
|
||||
textPreviewNode: Text;
|
||||
} {
|
||||
@@ -179,22 +172,14 @@ export class CommentView implements IRenderedElement {
|
||||
},
|
||||
topBarGroup,
|
||||
);
|
||||
// TODO: Before merging, does this mean to override an individual image,
|
||||
// folks need to replace the whole media folder?
|
||||
const deleteIcon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
{
|
||||
'class': 'blocklyDeleteIcon',
|
||||
'href': `${workspace.options.pathToMedia}delete-icon.svg`,
|
||||
},
|
||||
const deleteButton = new DeleteCommentBarButton(
|
||||
this.commentId,
|
||||
this.workspace,
|
||||
topBarGroup,
|
||||
);
|
||||
const foldoutIcon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
{
|
||||
'class': 'blocklyFoldoutIcon',
|
||||
'href': `${workspace.options.pathToMedia}foldout-icon.svg`,
|
||||
},
|
||||
const foldoutButton = new CollapseCommentBarButton(
|
||||
this.commentId,
|
||||
this.workspace,
|
||||
topBarGroup,
|
||||
);
|
||||
const textPreview = dom.createSvgElement(
|
||||
@@ -207,27 +192,11 @@ export class CommentView implements IRenderedElement {
|
||||
const textPreviewNode = document.createTextNode('');
|
||||
textPreview.appendChild(textPreviewNode);
|
||||
|
||||
// TODO(toychest): Triggering this on pointerdown means that we can't start
|
||||
// drags on the foldout icon. We need to open up the gesture system
|
||||
// to fix this.
|
||||
browserEvents.conditionalBind(
|
||||
foldoutIcon,
|
||||
'pointerdown',
|
||||
this,
|
||||
this.onFoldoutDown,
|
||||
);
|
||||
browserEvents.conditionalBind(
|
||||
deleteIcon,
|
||||
'pointerdown',
|
||||
this,
|
||||
this.onDeleteDown,
|
||||
);
|
||||
|
||||
return {
|
||||
topBarGroup,
|
||||
topBarBackground,
|
||||
deleteIcon,
|
||||
foldoutIcon,
|
||||
deleteButton,
|
||||
foldoutButton,
|
||||
textPreview,
|
||||
textPreviewNode,
|
||||
};
|
||||
@@ -236,33 +205,32 @@ export class CommentView implements IRenderedElement {
|
||||
/**
|
||||
* Creates the text area where users can type. Registers event listeners.
|
||||
*/
|
||||
private createTextArea(svgRoot: SVGGElement): {
|
||||
foreignObject: SVGForeignObjectElement;
|
||||
textArea: HTMLTextAreaElement;
|
||||
} {
|
||||
const foreignObject = dom.createSvgElement(
|
||||
Svg.FOREIGNOBJECT,
|
||||
{
|
||||
'class': 'blocklyCommentForeignObject',
|
||||
},
|
||||
svgRoot,
|
||||
private createTextArea() {
|
||||
// When the user is done editing comment, focus the entire comment.
|
||||
const onFinishEditing = () => this.svgRoot.focus();
|
||||
const commentEditor = new CommentEditor(
|
||||
this.workspace,
|
||||
this.commentId,
|
||||
onFinishEditing,
|
||||
);
|
||||
const body = document.createElementNS(dom.HTML_NS, 'body');
|
||||
body.setAttribute('xmlns', dom.HTML_NS);
|
||||
body.className = 'blocklyMinimalBody';
|
||||
const textArea = document.createElementNS(
|
||||
dom.HTML_NS,
|
||||
'textarea',
|
||||
) as HTMLTextAreaElement;
|
||||
dom.addClass(textArea, 'blocklyCommentText');
|
||||
dom.addClass(textArea, 'blocklyTextarea');
|
||||
dom.addClass(textArea, 'blocklyText');
|
||||
body.appendChild(textArea);
|
||||
foreignObject.appendChild(body);
|
||||
|
||||
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
|
||||
this.svgRoot.appendChild(commentEditor.getDom());
|
||||
|
||||
return {foreignObject, textArea};
|
||||
commentEditor.addTextChangeListener((oldText, newText) => {
|
||||
this.updateTextPreview(newText);
|
||||
// Update size in case our minimum size increased.
|
||||
this.setSize(this.size);
|
||||
});
|
||||
|
||||
return commentEditor;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns The FocusableNode representing the editor portion of this comment.
|
||||
*/
|
||||
getEditorFocusableNode(): IFocusableNode {
|
||||
return this.commentEditor;
|
||||
}
|
||||
|
||||
/** Creates the DOM elements for the comment resize handle. */
|
||||
@@ -308,15 +276,10 @@ export class CommentView implements IRenderedElement {
|
||||
*/
|
||||
setSizeWithoutFiringEvents(size: Size) {
|
||||
const topBarSize = this.topBarBackground.getBBox();
|
||||
const deleteSize = this.deleteIcon.getBBox();
|
||||
const foldoutSize = this.foldoutIcon.getBBox();
|
||||
const textPreviewSize = this.textPreview.getBBox();
|
||||
const resizeSize = this.resizeHandle.getBBox();
|
||||
|
||||
size = Size.max(
|
||||
size,
|
||||
this.calcMinSize(topBarSize, foldoutSize, deleteSize),
|
||||
);
|
||||
size = Size.max(size, this.calcMinSize(topBarSize));
|
||||
this.size = size;
|
||||
|
||||
this.svgRoot.setAttribute('height', `${size.height}`);
|
||||
@@ -324,16 +287,10 @@ export class CommentView implements IRenderedElement {
|
||||
|
||||
this.updateHighlightRect(size);
|
||||
this.updateTopBarSize(size);
|
||||
this.updateTextAreaSize(size, topBarSize);
|
||||
this.updateDeleteIconPosition(size, topBarSize, deleteSize);
|
||||
this.updateFoldoutIconPosition(topBarSize, foldoutSize);
|
||||
this.updateTextPreviewSize(
|
||||
size,
|
||||
topBarSize,
|
||||
textPreviewSize,
|
||||
deleteSize,
|
||||
resizeSize,
|
||||
);
|
||||
this.commentEditor.updateSize(size, topBarSize);
|
||||
this.deleteButton.reposition();
|
||||
this.foldoutButton.reposition();
|
||||
this.updateTextPreviewSize(size, topBarSize, textPreviewSize);
|
||||
this.updateResizeHandlePosition(size, resizeSize);
|
||||
}
|
||||
|
||||
@@ -355,25 +312,18 @@ export class CommentView implements IRenderedElement {
|
||||
*
|
||||
* The minimum height is based on the height of the top bar.
|
||||
*/
|
||||
private calcMinSize(
|
||||
topBarSize: Size,
|
||||
foldoutSize: Size,
|
||||
deleteSize: Size,
|
||||
): Size {
|
||||
this.updateTextPreview(this.textArea.value ?? '');
|
||||
private calcMinSize(topBarSize: Size): Size {
|
||||
this.updateTextPreview(this.commentEditor.getText() ?? '');
|
||||
const textPreviewWidth = dom.getTextWidth(this.textPreview);
|
||||
|
||||
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
|
||||
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
|
||||
|
||||
let width = textPreviewWidth;
|
||||
if (this.foldoutIcon.checkVisibility()) {
|
||||
width += foldoutSize.width + foldoutMargin * 2;
|
||||
if (this.foldoutButton.isVisible()) {
|
||||
width += this.foldoutButton.getSize(true).getWidth();
|
||||
} else if (textPreviewWidth) {
|
||||
width += 4; // Arbitrary margin before text.
|
||||
}
|
||||
if (this.deleteIcon.checkVisibility()) {
|
||||
width += deleteSize.width + deleteMargin * 2;
|
||||
if (this.deleteButton.isVisible()) {
|
||||
width += this.deleteButton.getSize(true).getWidth();
|
||||
} else if (textPreviewWidth) {
|
||||
width += 4; // Arbitrary margin after text.
|
||||
}
|
||||
@@ -384,16 +334,6 @@ export class CommentView implements IRenderedElement {
|
||||
return new Size(width, height);
|
||||
}
|
||||
|
||||
/** Calculates the margin that should exist around the delete icon. */
|
||||
private calcDeleteMargin(topBarSize: Size, deleteSize: Size) {
|
||||
return (topBarSize.height - deleteSize.height) / 2;
|
||||
}
|
||||
|
||||
/** Calculates the margin that should exist around the foldout icon. */
|
||||
private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) {
|
||||
return (topBarSize.height - foldoutSize.height) / 2;
|
||||
}
|
||||
|
||||
/** Updates the size of the highlight rect to reflect the new size. */
|
||||
private updateHighlightRect(size: Size) {
|
||||
this.highlightRect.setAttribute('height', `${size.height}`);
|
||||
@@ -408,44 +348,6 @@ export class CommentView implements IRenderedElement {
|
||||
this.topBarBackground.setAttribute('width', `${size.width}`);
|
||||
}
|
||||
|
||||
/** Updates the size of the text area elements to reflect the new size. */
|
||||
private updateTextAreaSize(size: Size, topBarSize: Size) {
|
||||
this.foreignObject.setAttribute(
|
||||
'height',
|
||||
`${size.height - topBarSize.height}`,
|
||||
);
|
||||
this.foreignObject.setAttribute('width', `${size.width}`);
|
||||
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
|
||||
if (this.workspace.RTL) {
|
||||
this.foreignObject.setAttribute('x', `${-size.width}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the delete icon elements to reflect the new size.
|
||||
*/
|
||||
private updateDeleteIconPosition(
|
||||
size: Size,
|
||||
topBarSize: Size,
|
||||
deleteSize: Size,
|
||||
) {
|
||||
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
|
||||
this.deleteIcon.setAttribute('y', `${deleteMargin}`);
|
||||
this.deleteIcon.setAttribute(
|
||||
'x',
|
||||
`${size.width - deleteSize.width - deleteMargin}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the foldout icon elements to reflect the new size.
|
||||
*/
|
||||
private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) {
|
||||
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
|
||||
this.foldoutIcon.setAttribute('y', `${foldoutMargin}`);
|
||||
this.foldoutIcon.setAttribute('x', `${foldoutMargin}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the size and position of the text preview elements to reflect the new size.
|
||||
*/
|
||||
@@ -453,25 +355,14 @@ export class CommentView implements IRenderedElement {
|
||||
size: Size,
|
||||
topBarSize: Size,
|
||||
textPreviewSize: Size,
|
||||
deleteSize: Size,
|
||||
foldoutSize: Size,
|
||||
) {
|
||||
const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2;
|
||||
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
|
||||
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
|
||||
const foldoutSize = this.foldoutButton.getSize(true);
|
||||
const deleteSize = this.deleteButton.getSize(true);
|
||||
|
||||
const textPreviewWidth =
|
||||
size.width -
|
||||
foldoutSize.width -
|
||||
foldoutMargin * 2 -
|
||||
deleteSize.width -
|
||||
deleteMargin * 2;
|
||||
this.textPreview.setAttribute(
|
||||
'x',
|
||||
`${
|
||||
foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1)
|
||||
}`,
|
||||
);
|
||||
size.width - foldoutSize.getWidth() - deleteSize.getWidth();
|
||||
this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`);
|
||||
this.textPreview.setAttribute(
|
||||
'y',
|
||||
`${textPreviewMargin + textPreviewSize.height / 2}`,
|
||||
@@ -622,25 +513,6 @@ export class CommentView implements IRenderedElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the collapsedness of the block when we receive a pointer down
|
||||
* event on the foldout icon.
|
||||
*/
|
||||
private onFoldoutDown(e: PointerEvent) {
|
||||
touch.clearTouchIdentifier();
|
||||
this.bringToFront();
|
||||
if (browserEvents.isRightButton(e)) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCollapsed(!this.collapsed);
|
||||
|
||||
this.workspace.hideChaff();
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/** Returns true if the comment is currently editable. */
|
||||
isEditable(): boolean {
|
||||
return this.editable;
|
||||
@@ -652,12 +524,11 @@ export class CommentView implements IRenderedElement {
|
||||
if (this.editable) {
|
||||
dom.addClass(this.svgRoot, 'blocklyEditable');
|
||||
dom.removeClass(this.svgRoot, 'blocklyReadonly');
|
||||
this.textArea.removeAttribute('readonly');
|
||||
} else {
|
||||
dom.removeClass(this.svgRoot, 'blocklyEditable');
|
||||
dom.addClass(this.svgRoot, 'blocklyReadonly');
|
||||
this.textArea.setAttribute('readonly', 'true');
|
||||
}
|
||||
this.commentEditor.setEditable(editable);
|
||||
}
|
||||
|
||||
/** Returns the current location of the comment in workspace coordinates. */
|
||||
@@ -678,49 +549,29 @@ export class CommentView implements IRenderedElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** Retursn the current text of the comment. */
|
||||
/** Returns the current text of the comment. */
|
||||
getText() {
|
||||
return this.text;
|
||||
return this.commentEditor.getText();
|
||||
}
|
||||
|
||||
/** Sets the current text of the comment. */
|
||||
setText(text: string) {
|
||||
this.textArea.value = text;
|
||||
this.onTextChange();
|
||||
this.commentEditor.setText(text);
|
||||
}
|
||||
|
||||
/** Sets the placeholder text displayed for an empty comment. */
|
||||
setPlaceholderText(text: string) {
|
||||
this.textArea.placeholder = text;
|
||||
this.commentEditor.setPlaceholderText(text);
|
||||
}
|
||||
|
||||
/** Registers a callback that listens for text changes. */
|
||||
/** Registers a callback that listens for text changes on the comment editor. */
|
||||
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
|
||||
this.textChangeListeners.push(listener);
|
||||
this.commentEditor.addTextChangeListener(listener);
|
||||
}
|
||||
|
||||
/** Removes the given listener from the list of text change listeners. */
|
||||
/** Removes the given listener from the comment editor. */
|
||||
removeTextChangeListener(listener: () => void) {
|
||||
this.textChangeListeners.splice(
|
||||
this.textChangeListeners.indexOf(listener),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers listeners when the text of the comment changes, either
|
||||
* programmatically or manually by the user.
|
||||
*/
|
||||
private onTextChange() {
|
||||
const oldText = this.text;
|
||||
this.text = this.textArea.value;
|
||||
this.updateTextPreview(this.text);
|
||||
// Update size in case our minimum size increased.
|
||||
this.setSize(this.size);
|
||||
// Loop through listeners backwards in case they remove themselves.
|
||||
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
|
||||
this.textChangeListeners[i](oldText, this.text);
|
||||
}
|
||||
this.commentEditor.removeTextChangeListener(listener);
|
||||
}
|
||||
|
||||
/** Updates the preview text element to reflect the given text. */
|
||||
@@ -734,7 +585,7 @@ export class CommentView implements IRenderedElement {
|
||||
}
|
||||
|
||||
/** Brings the workspace comment to the front of its layer. */
|
||||
private bringToFront() {
|
||||
bringToFront() {
|
||||
const parent = this.svgRoot.parentNode;
|
||||
const childNodes = parent!.childNodes;
|
||||
// Avoid moving the comment if it's already at the bottom.
|
||||
@@ -761,6 +612,8 @@ export class CommentView implements IRenderedElement {
|
||||
/** Disposes of this comment view. */
|
||||
dispose() {
|
||||
this.disposing = true;
|
||||
this.foldoutButton.dispose();
|
||||
this.deleteButton.dispose();
|
||||
dom.removeNode(this.svgRoot);
|
||||
// Loop through listeners backwards in case they remove themselves.
|
||||
for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
|
||||
@@ -791,6 +644,13 @@ export class CommentView implements IRenderedElement {
|
||||
removeDisposeListener(listener: () => void) {
|
||||
this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
getCommentBarButtons(): CommentBarButton[] {
|
||||
return [this.foldoutButton, this.deleteButton];
|
||||
}
|
||||
}
|
||||
|
||||
css.register(`
|
||||
@@ -884,6 +744,11 @@ css.register(`
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.blocklyCommentText.blocklyActiveFocus {
|
||||
border-color: #fc3;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.blocklySelected .blocklyCommentHighlight {
|
||||
stroke: #fc3;
|
||||
stroke-width: 3px;
|
||||
|
||||
104
core/comments/delete_comment_bar_button.ts
Normal file
104
core/comments/delete_comment_bar_button.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {CommentBarButton} from './comment_bar_button.js';
|
||||
|
||||
/**
|
||||
* Magic string appended to the comment ID to create a unique ID for this button.
|
||||
*/
|
||||
export const COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER = '_delete_bar_button';
|
||||
|
||||
/**
|
||||
* Button that deletes a comment.
|
||||
*/
|
||||
export class DeleteCommentBarButton extends CommentBarButton {
|
||||
/**
|
||||
* Opaque ID used to unbind event handlers during disposal.
|
||||
*/
|
||||
private readonly bindId: browserEvents.Data;
|
||||
|
||||
/**
|
||||
* SVG image displayed on this button.
|
||||
*/
|
||||
protected override readonly icon: SVGImageElement;
|
||||
|
||||
/**
|
||||
* Creates a new DeleteCommentBarButton instance.
|
||||
*
|
||||
* @param id The ID of this button's parent comment.
|
||||
* @param workspace The workspace this button's parent comment is shown on.
|
||||
* @param container An SVG group that this button should be a child of.
|
||||
*/
|
||||
constructor(
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
) {
|
||||
super(id, workspace, container);
|
||||
|
||||
this.icon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
{
|
||||
'class': 'blocklyDeleteIcon',
|
||||
'href': `${this.workspace.options.pathToMedia}delete-icon.svg`,
|
||||
'id': `${this.id}${COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
|
||||
},
|
||||
container,
|
||||
);
|
||||
this.bindId = browserEvents.conditionalBind(
|
||||
this.icon,
|
||||
'pointerdown',
|
||||
this,
|
||||
this.performAction.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of this button.
|
||||
*/
|
||||
dispose() {
|
||||
browserEvents.unbind(this.bindId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the positioning of this button within its container.
|
||||
*/
|
||||
override reposition() {
|
||||
const margin = this.getMargin();
|
||||
// Reset to 0 so that our position doesn't force the parent container to
|
||||
// grow.
|
||||
this.icon.setAttribute('x', `0`);
|
||||
const containerSize = this.container.getBBox();
|
||||
this.icon.setAttribute('y', `${margin}`);
|
||||
this.icon.setAttribute(
|
||||
'x',
|
||||
`${containerSize.width - this.getSize(true).getWidth()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes parent comment.
|
||||
*
|
||||
* @param e The event that triggered this action.
|
||||
*/
|
||||
override performAction(e?: Event) {
|
||||
touch.clearTouchIdentifier();
|
||||
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.getParentComment().dispose();
|
||||
e?.stopPropagation();
|
||||
getFocusManager().focusNode(this.workspace);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,12 @@ export abstract class Field<T = any>
|
||||
throw Error('Field already bound to a block');
|
||||
}
|
||||
this.sourceBlock_ = block;
|
||||
if (block.id.includes('_field')) {
|
||||
throw new Error(
|
||||
`Field ID indicator is contained in block ID. This may cause ` +
|
||||
`problems with focus: ${block.id}.`,
|
||||
);
|
||||
}
|
||||
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
FieldValidator,
|
||||
UnattachedFieldError,
|
||||
} from './field.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import {Msg} from './msg.js';
|
||||
import * as renderManagement from './render_management.js';
|
||||
@@ -83,8 +84,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
/** Key down event data. */
|
||||
private onKeyDownWrapper: browserEvents.Data | null = null;
|
||||
|
||||
/** Key input event data. */
|
||||
private onKeyInputWrapper: browserEvents.Data | null = null;
|
||||
/** Input element input event data. */
|
||||
private onInputWrapper: browserEvents.Data | null = null;
|
||||
|
||||
/**
|
||||
* Whether the field should consider the whole parent block to be its click
|
||||
@@ -558,7 +559,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
this.onHtmlInputKeyDown_,
|
||||
);
|
||||
// Resize after every input change.
|
||||
this.onKeyInputWrapper = browserEvents.conditionalBind(
|
||||
this.onInputWrapper = browserEvents.conditionalBind(
|
||||
htmlInput,
|
||||
'input',
|
||||
this,
|
||||
@@ -572,9 +573,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
browserEvents.unbind(this.onKeyDownWrapper);
|
||||
this.onKeyDownWrapper = null;
|
||||
}
|
||||
if (this.onKeyInputWrapper) {
|
||||
browserEvents.unbind(this.onKeyInputWrapper);
|
||||
this.onKeyInputWrapper = null;
|
||||
if (this.onInputWrapper) {
|
||||
browserEvents.unbind(this.onInputWrapper);
|
||||
this.onInputWrapper = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +615,14 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
if (target instanceof FieldInput) {
|
||||
WidgetDiv.hideIfOwner(this);
|
||||
dropDownDiv.hideWithoutAnimation();
|
||||
const targetSourceBlock = target.getSourceBlock();
|
||||
if (
|
||||
target.isFullBlockField() &&
|
||||
targetSourceBlock &&
|
||||
targetSourceBlock instanceof BlockSvg
|
||||
) {
|
||||
getFocusManager().focusNode(targetSourceBlock);
|
||||
} else getFocusManager().focusNode(target);
|
||||
target.showEditor();
|
||||
}
|
||||
}
|
||||
@@ -622,7 +631,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
/**
|
||||
* Handle a change to the editor.
|
||||
*
|
||||
* @param _e Keyboard event.
|
||||
* @param _e InputEvent.
|
||||
*/
|
||||
private onHtmlInputChange(_e: Event) {
|
||||
// Intermediate value changes from user input are not confirmed until the
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface ICopyable<T extends ICopyData> extends ISelectable {
|
||||
* @returns Copy metadata.
|
||||
*/
|
||||
toCopyData(): T | null;
|
||||
|
||||
/**
|
||||
* Whether this instance is currently copyable. The standard implementation
|
||||
* is to return true if isOwnDeletable and isOwnMovable return true.
|
||||
*
|
||||
* @returns True if it can currently be copied.
|
||||
*/
|
||||
isCopyable?(): boolean;
|
||||
}
|
||||
|
||||
export namespace ICopyable {
|
||||
@@ -25,7 +33,7 @@ export namespace ICopyable {
|
||||
|
||||
export type ICopyData = ICopyable.ICopyData;
|
||||
|
||||
/** @returns true if the given object is copyable. */
|
||||
/** @returns true if the given object is an ICopyable. */
|
||||
export function isCopyable(obj: any): obj is ICopyable<ICopyData> {
|
||||
return obj.toCopyData !== undefined;
|
||||
return obj && typeof obj.toCopyData === 'function';
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@ export interface IPaster<U extends ICopyData, T extends ICopyable<U>> {
|
||||
export function isPaster(
|
||||
obj: any,
|
||||
): obj is IPaster<ICopyData, ICopyable<ICopyData>> {
|
||||
return obj.paste !== undefined;
|
||||
return obj && typeof obj.paste === 'function';
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ import {BlockSvg} from '../block_svg.js';
|
||||
import {ConnectionType} from '../connection_type.js';
|
||||
import type {Field} from '../field.js';
|
||||
import type {Icon} from '../icons/icon.js';
|
||||
import type {IBoundedElement} from '../interfaces/i_bounded_element.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import type {ISelectable} from '../interfaces/i_selectable.js';
|
||||
import {RenderedConnection} from '../rendered_connection.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
@@ -24,7 +27,7 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
|
||||
* @returns The first field or input of the given block, if any.
|
||||
*/
|
||||
getFirstChild(current: BlockSvg): IFocusableNode | null {
|
||||
const candidates = getBlockNavigationCandidates(current);
|
||||
const candidates = getBlockNavigationCandidates(current, true);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
@@ -58,6 +61,8 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
|
||||
return current.nextConnection?.targetBlock();
|
||||
} else if (current.outputConnection?.targetBlock()) {
|
||||
return navigateBlock(current, 1);
|
||||
} else if (current.getSurroundParent()) {
|
||||
return navigateBlock(current.getTopStackBlock(), 1);
|
||||
} else if (this.getParent(current) instanceof WorkspaceSvg) {
|
||||
return navigateStacks(current, 1);
|
||||
}
|
||||
@@ -111,14 +116,27 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
|
||||
* @param block The block to retrieve the navigable children of.
|
||||
* @returns A list of navigable/focusable children of the given block.
|
||||
*/
|
||||
function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] {
|
||||
function getBlockNavigationCandidates(
|
||||
block: BlockSvg,
|
||||
forward: boolean,
|
||||
): IFocusableNode[] {
|
||||
const candidates: IFocusableNode[] = block.getIcons();
|
||||
|
||||
for (const input of block.inputList) {
|
||||
if (!input.isVisible()) continue;
|
||||
candidates.push(...input.fieldRow);
|
||||
if (input.connection?.targetBlock()) {
|
||||
candidates.push(input.connection.targetBlock() as BlockSvg);
|
||||
const connectedBlock = input.connection.targetBlock() as BlockSvg;
|
||||
if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
|
||||
const lastStackBlock = connectedBlock
|
||||
.lastConnectionInStack(false)
|
||||
?.getSourceBlock();
|
||||
if (lastStackBlock) {
|
||||
candidates.push(lastStackBlock);
|
||||
}
|
||||
} else {
|
||||
candidates.push(connectedBlock);
|
||||
}
|
||||
} else if (input.connection?.type === ConnectionType.INPUT_VALUE) {
|
||||
candidates.push(input.connection as RenderedConnection);
|
||||
}
|
||||
@@ -128,21 +146,25 @@ function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next/previous stack relative to the given block's stack.
|
||||
* Returns the next/previous stack relative to the given element's stack.
|
||||
*
|
||||
* @param current The block whose stack will be navigated relative to.
|
||||
* @param current The element whose stack will be navigated relative to.
|
||||
* @param delta The difference in index to navigate; positive values navigate
|
||||
* to the nth next stack, while negative values navigate to the nth previous
|
||||
* stack.
|
||||
* @returns The first block in the stack offset by `delta` relative to the
|
||||
* current block's stack, or the last block in the stack offset by `delta`
|
||||
* relative to the current block's stack when navigating backwards.
|
||||
* @returns The first element in the stack offset by `delta` relative to the
|
||||
* current element's stack, or the last element in the stack offset by
|
||||
* `delta` relative to the current element's stack when navigating backwards.
|
||||
*/
|
||||
export function navigateStacks(current: BlockSvg, delta: number) {
|
||||
const stacks = current.workspace.getTopBlocks(true);
|
||||
const currentIndex = stacks.indexOf(current.getRootBlock());
|
||||
export function navigateStacks(current: ISelectable, delta: number) {
|
||||
const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg)
|
||||
.getTopBoundedElements(true)
|
||||
.filter((element: IBoundedElement) => isFocusableNode(element));
|
||||
const currentIndex = stacks.indexOf(
|
||||
current instanceof BlockSvg ? current.getRootBlock() : current,
|
||||
);
|
||||
const targetIndex = currentIndex + delta;
|
||||
let result: BlockSvg | null = null;
|
||||
let result: IFocusableNode | null = null;
|
||||
if (targetIndex >= 0 && targetIndex < stacks.length) {
|
||||
result = stacks[targetIndex];
|
||||
} else if (targetIndex < 0) {
|
||||
@@ -151,9 +173,9 @@ export function navigateStacks(current: BlockSvg, delta: number) {
|
||||
result = stacks[0];
|
||||
}
|
||||
|
||||
// When navigating to a previous stack, our previous sibling is the last
|
||||
// When navigating to a previous block stack, our previous sibling is the last
|
||||
// block in it.
|
||||
if (delta < 0 && result) {
|
||||
if (delta < 0 && result instanceof BlockSvg) {
|
||||
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
|
||||
}
|
||||
|
||||
@@ -174,11 +196,11 @@ export function navigateBlock(
|
||||
): IFocusableNode | null {
|
||||
const block =
|
||||
current instanceof BlockSvg
|
||||
? current.outputConnection.targetBlock()
|
||||
? (current.outputConnection?.targetBlock() ?? current.getSurroundParent())
|
||||
: current.getSourceBlock();
|
||||
if (!(block instanceof BlockSvg)) return null;
|
||||
|
||||
const candidates = getBlockNavigationCandidates(block);
|
||||
const candidates = getBlockNavigationCandidates(block, delta > 0);
|
||||
const currentIndex = candidates.indexOf(current);
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
|
||||
86
core/keyboard_nav/comment_bar_button_navigation_policy.ts
Normal file
86
core/keyboard_nav/comment_bar_button_navigation_policy.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {CommentBarButton} from '../comments/comment_bar_button.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a CommentBarButton.
|
||||
*/
|
||||
export class CommentBarButtonNavigationPolicy
|
||||
implements INavigationPolicy<CommentBarButton>
|
||||
{
|
||||
/**
|
||||
* Returns the first child of the given CommentBarButton.
|
||||
*
|
||||
* @param _current The CommentBarButton to return the first child of.
|
||||
* @returns Null.
|
||||
*/
|
||||
getFirstChild(_current: CommentBarButton): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given CommentBarButton.
|
||||
*
|
||||
* @param current The CommentBarButton to return the parent of.
|
||||
* @returns The parent comment of the given CommentBarButton.
|
||||
*/
|
||||
getParent(current: CommentBarButton): IFocusableNode | null {
|
||||
return current.getParentComment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given CommentBarButton.
|
||||
*
|
||||
* @param current The CommentBarButton to find the following element of.
|
||||
* @returns The next CommentBarButton, if any.
|
||||
*/
|
||||
getNextSibling(current: CommentBarButton): IFocusableNode | null {
|
||||
const children = current.getParentComment().view.getCommentBarButtons();
|
||||
const currentIndex = children.indexOf(current);
|
||||
if (currentIndex >= 0 && currentIndex + 1 < children.length) {
|
||||
return children[currentIndex + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given CommentBarButton.
|
||||
*
|
||||
* @param current The CommentBarButton to find the preceding element of.
|
||||
* @returns The CommentBarButton's previous CommentBarButton, if any.
|
||||
*/
|
||||
getPreviousSibling(current: CommentBarButton): IFocusableNode | null {
|
||||
const children = current.getParentComment().view.getCommentBarButtons();
|
||||
const currentIndex = children.indexOf(current);
|
||||
if (currentIndex > 0) {
|
||||
return children[currentIndex - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given CommentBarButton can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given CommentBarButton can be focused.
|
||||
*/
|
||||
isNavigable(current: CommentBarButton): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is an CommentBarButton.
|
||||
*/
|
||||
isApplicable(current: any): current is CommentBarButton {
|
||||
return current instanceof CommentBarButton;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
*/
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {Icon} from '../icons/icon.js';
|
||||
import {MutatorIcon} from '../icons/mutator_icon.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import {navigateBlock} from './block_navigation_policy.js';
|
||||
@@ -17,10 +19,18 @@ export class IconNavigationPolicy implements INavigationPolicy<Icon> {
|
||||
/**
|
||||
* Returns the first child of the given icon.
|
||||
*
|
||||
* @param _current The icon to return the first child of.
|
||||
* @param current The icon to return the first child of.
|
||||
* @returns Null.
|
||||
*/
|
||||
getFirstChild(_current: Icon): IFocusableNode | null {
|
||||
getFirstChild(current: Icon): IFocusableNode | null {
|
||||
if (
|
||||
current instanceof MutatorIcon &&
|
||||
current.bubbleIsVisible() &&
|
||||
getFocusManager().getFocusedNode() === current
|
||||
) {
|
||||
return current.getBubble()?.getWorkspace() ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
77
core/keyboard_nav/workspace_comment_navigation_policy.ts
Normal file
77
core/keyboard_nav/workspace_comment_navigation_policy.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import {navigateStacks} from './block_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from an RenderedWorkspaceComment.
|
||||
*/
|
||||
export class WorkspaceCommentNavigationPolicy
|
||||
implements INavigationPolicy<RenderedWorkspaceComment>
|
||||
{
|
||||
/**
|
||||
* Returns the first child of the given workspace comment.
|
||||
*
|
||||
* @param current The workspace comment to return the first child of.
|
||||
* @returns The first child button of the given comment.
|
||||
*/
|
||||
getFirstChild(current: RenderedWorkspaceComment): IFocusableNode | null {
|
||||
return current.view.getCommentBarButtons()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given workspace comment.
|
||||
*
|
||||
* @param current The workspace comment to return the parent of.
|
||||
* @returns The parent workspace of the given comment.
|
||||
*/
|
||||
getParent(current: RenderedWorkspaceComment): IFocusableNode | null {
|
||||
return current.workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given workspace comment.
|
||||
*
|
||||
* @param current The workspace comment to find the following element of.
|
||||
* @returns The next workspace comment or block stack, if any.
|
||||
*/
|
||||
getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null {
|
||||
return navigateStacks(current, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given workspace comment.
|
||||
*
|
||||
* @param current The workspace comment to find the preceding element of.
|
||||
* @returns The previous workspace comment or block stack, if any.
|
||||
*/
|
||||
getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null {
|
||||
return navigateStacks(current, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given workspace comment can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given workspace comment can be focused.
|
||||
*/
|
||||
isNavigable(current: RenderedWorkspaceComment): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is an RenderedWorkspaceComment.
|
||||
*/
|
||||
isApplicable(current: any): current is RenderedWorkspaceComment {
|
||||
return current instanceof RenderedWorkspaceComment;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
// Former goog.module ID: Blockly.MarkerManager
|
||||
|
||||
import type {LineCursor} from './keyboard_nav/line_cursor.js';
|
||||
import {LineCursor} from './keyboard_nav/line_cursor.js';
|
||||
import type {Marker} from './keyboard_nav/marker.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
@@ -23,7 +23,7 @@ export class MarkerManager {
|
||||
static readonly LOCAL_MARKER = 'local_marker_1';
|
||||
|
||||
/** The cursor. */
|
||||
private cursor: LineCursor | null = null;
|
||||
private cursor: LineCursor;
|
||||
|
||||
/** The map of markers for the workspace. */
|
||||
private markers = new Map<string, Marker>();
|
||||
@@ -32,7 +32,9 @@ export class MarkerManager {
|
||||
* @param workspace The workspace for the marker manager.
|
||||
* @internal
|
||||
*/
|
||||
constructor(private readonly workspace: WorkspaceSvg) {}
|
||||
constructor(private readonly workspace: WorkspaceSvg) {
|
||||
this.cursor = new LineCursor(this.workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the marker by adding it to the map of markers.
|
||||
@@ -72,7 +74,7 @@ export class MarkerManager {
|
||||
*
|
||||
* @returns The cursor for this workspace.
|
||||
*/
|
||||
getCursor(): LineCursor | null {
|
||||
getCursor(): LineCursor {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
@@ -109,9 +111,6 @@ export class MarkerManager {
|
||||
this.unregisterMarker(markerId);
|
||||
}
|
||||
this.markers.clear();
|
||||
if (this.cursor) {
|
||||
this.cursor.dispose();
|
||||
this.cursor = null;
|
||||
}
|
||||
this.cursor.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
|
||||
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
|
||||
import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js';
|
||||
import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js';
|
||||
import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js';
|
||||
import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js';
|
||||
import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js';
|
||||
import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js';
|
||||
|
||||
type RuleList<T> = INavigationPolicy<T>[];
|
||||
@@ -29,6 +31,8 @@ export class Navigator {
|
||||
new ConnectionNavigationPolicy(),
|
||||
new WorkspaceNavigationPolicy(),
|
||||
new IconNavigationPolicy(),
|
||||
new WorkspaceCommentNavigationPolicy(),
|
||||
new CommentBarButtonNavigationPolicy(),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -64,9 +68,8 @@ export class Navigator {
|
||||
getFirstChild(current: IFocusableNode): IFocusableNode | null {
|
||||
const result = this.get(current)?.getFirstChild(current);
|
||||
if (!result) return null;
|
||||
// If the child isn't navigable, don't traverse into it; check its peers.
|
||||
if (!this.get(result)?.isNavigable(result)) {
|
||||
return this.getNextSibling(result);
|
||||
return this.getFirstChild(result) || this.getNextSibling(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -8,19 +8,12 @@
|
||||
|
||||
import {BlockSvg} from './block_svg.js';
|
||||
import * as clipboard from './clipboard.js';
|
||||
import {RenderedWorkspaceComment} from './comments.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import {Gesture} from './gesture.js';
|
||||
import {
|
||||
ICopyable,
|
||||
ICopyData,
|
||||
isCopyable as isICopyable,
|
||||
} from './interfaces/i_copyable.js';
|
||||
import {
|
||||
IDeletable,
|
||||
isDeletable as isIDeletable,
|
||||
} from './interfaces/i_deletable.js';
|
||||
import {IDraggable, isDraggable} from './interfaces/i_draggable.js';
|
||||
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
|
||||
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
|
||||
import {isDraggable} from './interfaces/i_draggable.js';
|
||||
import {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
@@ -73,7 +66,7 @@ export function registerDelete() {
|
||||
focused != null &&
|
||||
isIDeletable(focused) &&
|
||||
focused.isDeletable() &&
|
||||
!Gesture.inProgress() &&
|
||||
!workspace.isDragging() &&
|
||||
// Don't delete the block if a field editor is open
|
||||
!getFocusManager().ephemeralFocusTaken()
|
||||
);
|
||||
@@ -99,75 +92,41 @@ export function registerDelete() {
|
||||
ShortcutRegistry.registry.register(deleteShortcut);
|
||||
}
|
||||
|
||||
let copyData: ICopyData | null = null;
|
||||
let copyWorkspace: WorkspaceSvg | null = null;
|
||||
let copyCoords: Coordinate | null = null;
|
||||
|
||||
/**
|
||||
* Determine if a focusable node can be copied.
|
||||
*
|
||||
* Unfortunately the ICopyable interface doesn't include an isCopyable
|
||||
* method, so we must use some other criteria to make the decision.
|
||||
* Specifically,
|
||||
*
|
||||
* - It must be an ICopyable.
|
||||
* - So that a pasted copy can be manipluated and/or disposed of, it
|
||||
* must be both an IDraggable and an IDeletable.
|
||||
* - Additionally, both .isOwnMovable() and .isOwnDeletable() must return
|
||||
* true (i.e., the copy could be moved and deleted).
|
||||
*
|
||||
* TODO(#9098): Revise these criteria. The latter criteria prevents
|
||||
* shadow blocks from being copied; additionally, there are likely to
|
||||
* be other circumstances were it is desirable to allow movable /
|
||||
* copyable copies of a currently-unmovable / -copyable block to be
|
||||
* made.
|
||||
* This will use the isCopyable method if the node implements it, otherwise
|
||||
* it will fall back to checking if the node is deletable and draggable not
|
||||
* considering the workspace's edit state.
|
||||
*
|
||||
* @param focused The focused object.
|
||||
*/
|
||||
function isCopyable(
|
||||
focused: IFocusableNode,
|
||||
): focused is ICopyable<ICopyData> & IDeletable & IDraggable {
|
||||
if (!(focused instanceof BlockSvg)) return false;
|
||||
return (
|
||||
isICopyable(focused) &&
|
||||
isIDeletable(focused) &&
|
||||
focused.isOwnDeletable() &&
|
||||
isDraggable(focused) &&
|
||||
focused.isOwnMovable()
|
||||
);
|
||||
function isCopyable(focused: IFocusableNode): boolean {
|
||||
if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused))
|
||||
return false;
|
||||
if (focused.isCopyable) {
|
||||
return focused.isCopyable();
|
||||
} else if (
|
||||
focused instanceof BlockSvg ||
|
||||
focused instanceof RenderedWorkspaceComment
|
||||
) {
|
||||
return focused.isOwnDeletable() && focused.isOwnMovable();
|
||||
}
|
||||
// This isn't a class Blockly knows about, so fall back to the stricter
|
||||
// checks for deletable and movable.
|
||||
return focused.isDeletable() && focused.isMovable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a focusable node can be cut.
|
||||
*
|
||||
* Unfortunately the ICopyable interface doesn't include an isCuttable
|
||||
* method, so we must use some other criteria to make the decision.
|
||||
* Specifically,
|
||||
*
|
||||
* - It must be an ICopyable.
|
||||
* - So that a pasted copy can be manipluated and/or disposed of, it
|
||||
* must be both an IDraggable and an IDeletable.
|
||||
* - Additionally, both .isMovable() and .isDeletable() must return
|
||||
* true (i.e., can currently be moved and deleted). This is the main
|
||||
* difference with isCopyable.
|
||||
*
|
||||
* TODO(#9098): Revise these criteria. The latter criteria prevents
|
||||
* shadow blocks from being copied; additionally, there are likely to
|
||||
* be other circumstances were it is desirable to allow movable /
|
||||
* copyable copies of a currently-unmovable / -copyable block to be
|
||||
* made.
|
||||
* This will check if the node can be both copied and deleted in its current
|
||||
* workspace.
|
||||
*
|
||||
* @param focused The focused object.
|
||||
*/
|
||||
function isCuttable(focused: IFocusableNode): boolean {
|
||||
if (!(focused instanceof BlockSvg)) return false;
|
||||
return (
|
||||
isICopyable(focused) &&
|
||||
isIDeletable(focused) &&
|
||||
focused.isDeletable() &&
|
||||
isDraggable(focused) &&
|
||||
focused.isMovable()
|
||||
);
|
||||
return isCopyable(focused) && isIDeletable(focused) && focused.isDeletable();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +144,6 @@ export function registerCopy() {
|
||||
name: names.COPY,
|
||||
preconditionFn(workspace, scope) {
|
||||
const focused = scope.focusedNode;
|
||||
if (!(focused instanceof BlockSvg)) return false;
|
||||
|
||||
const targetWorkspace = workspace.isFlyout
|
||||
? workspace.targetWorkspace
|
||||
@@ -193,7 +151,6 @@ export function registerCopy() {
|
||||
return (
|
||||
!!focused &&
|
||||
!!targetWorkspace &&
|
||||
!targetWorkspace.isReadOnly() &&
|
||||
!targetWorkspace.isDragging() &&
|
||||
!getFocusManager().ephemeralFocusTaken() &&
|
||||
isCopyable(focused)
|
||||
@@ -205,26 +162,22 @@ export function registerCopy() {
|
||||
e.preventDefault();
|
||||
|
||||
const focused = scope.focusedNode;
|
||||
if (!focused || !isCopyable(focused)) return false;
|
||||
let targetWorkspace: WorkspaceSvg | null =
|
||||
focused.workspace instanceof WorkspaceSvg
|
||||
? focused.workspace
|
||||
: workspace;
|
||||
targetWorkspace = targetWorkspace.isFlyout
|
||||
? targetWorkspace.targetWorkspace
|
||||
: targetWorkspace;
|
||||
if (!focused || !isICopyable(focused) || !isCopyable(focused))
|
||||
return false;
|
||||
const targetWorkspace = workspace.isFlyout
|
||||
? workspace.targetWorkspace
|
||||
: workspace;
|
||||
if (!targetWorkspace) return false;
|
||||
|
||||
if (!focused.workspace.isFlyout) {
|
||||
targetWorkspace.hideChaff();
|
||||
}
|
||||
copyData = focused.toCopyData();
|
||||
copyWorkspace = targetWorkspace;
|
||||
copyCoords =
|
||||
|
||||
const copyCoords =
|
||||
isDraggable(focused) && focused.workspace == targetWorkspace
|
||||
? focused.getRelativeToSurfaceXY()
|
||||
: null;
|
||||
return !!copyData;
|
||||
: undefined;
|
||||
return !!clipboard.copy(focused, copyCoords);
|
||||
},
|
||||
keyCodes: [ctrlC, metaC],
|
||||
};
|
||||
@@ -256,27 +209,20 @@ export function registerCut() {
|
||||
},
|
||||
callback(workspace, e, shortcut, scope) {
|
||||
const focused = scope.focusedNode;
|
||||
if (!focused || !isCuttable(focused) || !isICopyable(focused)) {
|
||||
return false;
|
||||
}
|
||||
const copyCoords = isDraggable(focused)
|
||||
? focused.getRelativeToSurfaceXY()
|
||||
: undefined;
|
||||
const copyData = clipboard.copy(focused, copyCoords);
|
||||
|
||||
if (focused instanceof BlockSvg) {
|
||||
copyData = focused.toCopyData();
|
||||
copyWorkspace = workspace;
|
||||
copyCoords = focused.getRelativeToSurfaceXY();
|
||||
focused.checkAndDelete();
|
||||
return true;
|
||||
} else if (
|
||||
isIDeletable(focused) &&
|
||||
focused.isDeletable() &&
|
||||
isICopyable(focused)
|
||||
) {
|
||||
copyData = focused.toCopyData();
|
||||
copyWorkspace = workspace;
|
||||
copyCoords = isDraggable(focused)
|
||||
? focused.getRelativeToSurfaceXY()
|
||||
: null;
|
||||
} else if (isIDeletable(focused)) {
|
||||
focused.dispose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return !!copyData;
|
||||
},
|
||||
keyCodes: [ctrlX, metaX],
|
||||
};
|
||||
@@ -297,12 +243,19 @@ export function registerPaste() {
|
||||
|
||||
const pasteShortcut: KeyboardShortcut = {
|
||||
name: names.PASTE,
|
||||
preconditionFn(workspace) {
|
||||
preconditionFn() {
|
||||
// Regardless of the currently focused workspace, we will only
|
||||
// paste into the last-copied-from workspace.
|
||||
const workspace = clipboard.getLastCopiedWorkspace();
|
||||
// If we don't know where we copied from, we don't know where to paste.
|
||||
// If the workspace isn't rendered (e.g. closed mutator workspace),
|
||||
// we can't paste into it.
|
||||
if (!workspace || !workspace.rendered) return false;
|
||||
const targetWorkspace = workspace.isFlyout
|
||||
? workspace.targetWorkspace
|
||||
: workspace;
|
||||
return (
|
||||
!!copyData &&
|
||||
!!clipboard.getLastCopiedData() &&
|
||||
!!targetWorkspace &&
|
||||
!targetWorkspace.isReadOnly() &&
|
||||
!targetWorkspace.isDragging() &&
|
||||
@@ -310,7 +263,16 @@ export function registerPaste() {
|
||||
);
|
||||
},
|
||||
callback(workspace: WorkspaceSvg, e: Event) {
|
||||
if (!copyData || !copyWorkspace) return false;
|
||||
const copyData = clipboard.getLastCopiedData();
|
||||
if (!copyData) return false;
|
||||
|
||||
const copyWorkspace = clipboard.getLastCopiedWorkspace();
|
||||
if (!copyWorkspace) return false;
|
||||
|
||||
const targetWorkspace = copyWorkspace.isFlyout
|
||||
? copyWorkspace.targetWorkspace
|
||||
: copyWorkspace;
|
||||
if (!targetWorkspace || targetWorkspace.isReadOnly()) return false;
|
||||
|
||||
if (e instanceof PointerEvent) {
|
||||
// The event that triggers a shortcut would conventionally be a KeyboardEvent.
|
||||
@@ -319,19 +281,20 @@ export function registerPaste() {
|
||||
// at the mouse coordinates where the menu was opened, and this PointerEvent
|
||||
// is where the menu was opened.
|
||||
const mouseCoords = svgMath.screenToWsCoordinates(
|
||||
copyWorkspace,
|
||||
targetWorkspace,
|
||||
new Coordinate(e.clientX, e.clientY),
|
||||
);
|
||||
return !!clipboard.paste(copyData, copyWorkspace, mouseCoords);
|
||||
return !!clipboard.paste(copyData, targetWorkspace, mouseCoords);
|
||||
}
|
||||
|
||||
const copyCoords = clipboard.getLastCopiedLocation();
|
||||
if (!copyCoords) {
|
||||
// If we don't have location data about the original copyable, let the
|
||||
// paster determine position.
|
||||
return !!clipboard.paste(copyData, copyWorkspace);
|
||||
return !!clipboard.paste(copyData, targetWorkspace);
|
||||
}
|
||||
|
||||
const {left, top, width, height} = copyWorkspace
|
||||
const {left, top, width, height} = targetWorkspace
|
||||
.getMetricsManager()
|
||||
.getViewMetrics(true);
|
||||
const viewportRect = new Rect(top, top + height, left, left + width);
|
||||
@@ -339,12 +302,12 @@ export function registerPaste() {
|
||||
if (viewportRect.contains(copyCoords.x, copyCoords.y)) {
|
||||
// If the original copyable is inside the viewport, let the paster
|
||||
// determine position.
|
||||
return !!clipboard.paste(copyData, copyWorkspace);
|
||||
return !!clipboard.paste(copyData, targetWorkspace);
|
||||
}
|
||||
|
||||
// Otherwise, paste in the middle of the viewport.
|
||||
const centerCoords = new Coordinate(left + width / 2, top + height / 2);
|
||||
return !!clipboard.paste(copyData, copyWorkspace, centerCoords);
|
||||
return !!clipboard.paste(copyData, targetWorkspace, centerCoords);
|
||||
},
|
||||
keyCodes: [ctrlV, metaV],
|
||||
};
|
||||
@@ -368,7 +331,7 @@ export function registerUndo() {
|
||||
preconditionFn(workspace) {
|
||||
return (
|
||||
!workspace.isReadOnly() &&
|
||||
!Gesture.inProgress() &&
|
||||
!workspace.isDragging() &&
|
||||
!getFocusManager().ephemeralFocusTaken()
|
||||
);
|
||||
},
|
||||
@@ -390,12 +353,12 @@ export function registerUndo() {
|
||||
*/
|
||||
export function registerRedo() {
|
||||
const ctrlShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
|
||||
KeyCodes.SHIFT,
|
||||
KeyCodes.CTRL,
|
||||
KeyCodes.SHIFT,
|
||||
]);
|
||||
const metaShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
|
||||
KeyCodes.SHIFT,
|
||||
KeyCodes.META,
|
||||
KeyCodes.SHIFT,
|
||||
]);
|
||||
// Ctrl-y is redo in Windows. Command-y is never valid on Macs.
|
||||
const ctrlY = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Y, [
|
||||
@@ -406,7 +369,7 @@ export function registerRedo() {
|
||||
name: names.REDO,
|
||||
preconditionFn(workspace) {
|
||||
return (
|
||||
!Gesture.inProgress() &&
|
||||
!workspace.isDragging() &&
|
||||
!workspace.isReadOnly() &&
|
||||
!getFocusManager().ephemeralFocusTaken()
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ import type {Block} from './block.js';
|
||||
import type {BlockSvg} from './block_svg.js';
|
||||
import type {BlocklyOptions} from './blockly_options.js';
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js';
|
||||
import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js';
|
||||
import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js';
|
||||
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
|
||||
import {WorkspaceComment} from './comments/workspace_comment.js';
|
||||
import * as common from './common.js';
|
||||
@@ -41,6 +44,7 @@ import type {FlyoutButton} from './flyout_button.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import {Gesture} from './gesture.js';
|
||||
import {Grid} from './grid.js';
|
||||
import {MutatorIcon} from './icons/mutator_icon.js';
|
||||
import {isAutoHideable} from './interfaces/i_autohideable.js';
|
||||
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
import {IContextMenu} from './interfaces/i_contextmenu.js';
|
||||
@@ -476,10 +480,7 @@ export class WorkspaceSvg
|
||||
* @internal
|
||||
*/
|
||||
getMarker(id: string): Marker | null {
|
||||
if (this.markerManager) {
|
||||
return this.markerManager.getMarker(id);
|
||||
}
|
||||
return null;
|
||||
return this.markerManager.getMarker(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,11 +488,8 @@ export class WorkspaceSvg
|
||||
*
|
||||
* @returns The cursor for the workspace.
|
||||
*/
|
||||
getCursor(): LineCursor | null {
|
||||
if (this.markerManager) {
|
||||
return this.markerManager.getCursor();
|
||||
}
|
||||
return null;
|
||||
getCursor(): LineCursor {
|
||||
return this.markerManager.getCursor();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,10 +893,7 @@ export class WorkspaceSvg
|
||||
}
|
||||
|
||||
this.renderer.dispose();
|
||||
|
||||
if (this.markerManager) {
|
||||
this.markerManager.dispose();
|
||||
}
|
||||
this.markerManager.dispose();
|
||||
|
||||
super.dispose();
|
||||
|
||||
@@ -2264,8 +2259,8 @@ export class WorkspaceSvg
|
||||
*
|
||||
* @param comment comment to add.
|
||||
*/
|
||||
override addTopComment(comment: WorkspaceComment) {
|
||||
this.addTopBoundedElement(comment as RenderedWorkspaceComment);
|
||||
override addTopComment(comment: RenderedWorkspaceComment) {
|
||||
this.addTopBoundedElement(comment);
|
||||
super.addTopComment(comment);
|
||||
}
|
||||
|
||||
@@ -2274,11 +2269,31 @@ export class WorkspaceSvg
|
||||
*
|
||||
* @param comment comment to remove.
|
||||
*/
|
||||
override removeTopComment(comment: WorkspaceComment) {
|
||||
this.removeTopBoundedElement(comment as RenderedWorkspaceComment);
|
||||
override removeTopComment(comment: RenderedWorkspaceComment) {
|
||||
this.removeTopBoundedElement(comment);
|
||||
super.removeTopComment(comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of comments on this workspace.
|
||||
*
|
||||
* @param ordered If true, sorts the comments based on their position.
|
||||
* @returns A list of workspace comments.
|
||||
*/
|
||||
override getTopComments(ordered = false): RenderedWorkspaceComment[] {
|
||||
return super.getTopComments(ordered) as RenderedWorkspaceComment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the workspace comment with the given ID, if any.
|
||||
*
|
||||
* @param id The ID of the comment to retrieve.
|
||||
* @returns The workspace comment with the given ID, or null.
|
||||
*/
|
||||
override getCommentById(id: string): RenderedWorkspaceComment | null {
|
||||
return super.getCommentById(id) as RenderedWorkspaceComment | null;
|
||||
}
|
||||
|
||||
override getRootWorkspace(): WorkspaceSvg | null {
|
||||
return super.getRootWorkspace() as WorkspaceSvg | null;
|
||||
}
|
||||
@@ -2306,8 +2321,15 @@ export class WorkspaceSvg
|
||||
*
|
||||
* @returns The top-level bounded elements.
|
||||
*/
|
||||
getTopBoundedElements(): IBoundedElement[] {
|
||||
return new Array<IBoundedElement>().concat(this.topBoundedElements);
|
||||
getTopBoundedElements(ordered = false): IBoundedElement[] {
|
||||
const elements = new Array<IBoundedElement>().concat(
|
||||
this.topBoundedElements,
|
||||
);
|
||||
if (ordered) {
|
||||
elements.sort(this.sortByOrigin.bind(this));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2680,7 +2702,7 @@ export class WorkspaceSvg
|
||||
|
||||
/** See IFocusableNode.getFocusableTree. */
|
||||
getFocusableTree(): IFocusableTree {
|
||||
return this;
|
||||
return (this.isMutator && this.options.parentWorkspace) || this;
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
@@ -2710,7 +2732,42 @@ export class WorkspaceSvg
|
||||
|
||||
/** See IFocusableTree.getNestedTrees. */
|
||||
getNestedTrees(): Array<IFocusableTree> {
|
||||
return [];
|
||||
const nestedWorkspaces = this.getAllBlocks()
|
||||
.map((block) => block.getIcons())
|
||||
.flat()
|
||||
.filter(
|
||||
(icon): icon is MutatorIcon =>
|
||||
icon instanceof MutatorIcon && icon.bubbleIsVisible(),
|
||||
)
|
||||
.map((icon) => icon.getBubble()?.getWorkspace())
|
||||
.filter((workspace) => !!workspace);
|
||||
|
||||
const ownFlyout = this.getFlyout(true);
|
||||
if (ownFlyout) {
|
||||
nestedWorkspaces.push(ownFlyout.getWorkspace());
|
||||
}
|
||||
|
||||
return nestedWorkspaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for searching for a specific workspace comment.
|
||||
* We can't use this.getWorkspaceCommentById because the workspace
|
||||
* comment ids might not be globally unique, but the id assigned to
|
||||
* the focusable element for the comment should be.
|
||||
*/
|
||||
private searchForWorkspaceComment(
|
||||
id: string,
|
||||
): RenderedWorkspaceComment | undefined {
|
||||
for (const comment of this.getTopComments()) {
|
||||
if (
|
||||
comment instanceof RenderedWorkspaceComment &&
|
||||
comment.canBeFocused() &&
|
||||
comment.getFocusableElement().id === id
|
||||
) {
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** See IFocusableTree.lookUpFocusableNode. */
|
||||
@@ -2757,21 +2814,42 @@ export class WorkspaceSvg
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search for a specific workspace comment or comment icon if the ID
|
||||
// indicates the presence of one.
|
||||
const commentIdSeparatorIndex = Math.max(
|
||||
id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER),
|
||||
id.indexOf(COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER),
|
||||
id.indexOf(COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER),
|
||||
);
|
||||
if (commentIdSeparatorIndex !== -1) {
|
||||
const commentId = id.substring(0, commentIdSeparatorIndex);
|
||||
const comment = this.searchForWorkspaceComment(commentId);
|
||||
if (comment) {
|
||||
if (id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER) > -1) {
|
||||
return comment.getEditorFocusableNode();
|
||||
} else {
|
||||
return (
|
||||
comment.view
|
||||
.getCommentBarButtons()
|
||||
.find((button) => button.getFocusableElement().id.includes(id)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search for a specific block.
|
||||
// Don't use `getBlockById` because the block ID is not guaranteed
|
||||
// to be globally unique, but the ID on the focusable element is.
|
||||
const block = this.getAllBlocks(false).find(
|
||||
(block) => block.getFocusableElement().id === id,
|
||||
);
|
||||
if (block) return block;
|
||||
|
||||
// Search for a workspace comment (semi-expensive).
|
||||
for (const comment of this.getTopComments()) {
|
||||
if (
|
||||
comment instanceof RenderedWorkspaceComment &&
|
||||
comment.canBeFocused() &&
|
||||
comment.getFocusableElement().id === id
|
||||
) {
|
||||
return comment;
|
||||
}
|
||||
const comment = this.searchForWorkspaceComment(id);
|
||||
if (comment) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
// Search for icons and bubbles (which requires an expensive getAllBlocks).
|
||||
|
||||
@@ -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',
|
||||
|
||||
54
gulpfile.js
54
gulpfile.js
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Gulp script to build Blockly for Node & NPM.
|
||||
* Run this script by calling "npm install" in this directory.
|
||||
*/
|
||||
/* eslint-env node */
|
||||
|
||||
const gulp = require('gulp');
|
||||
|
||||
const buildTasks = require('./scripts/gulpfiles/build_tasks');
|
||||
const packageTasks = require('./scripts/gulpfiles/package_tasks');
|
||||
const gitTasks = require('./scripts/gulpfiles/git_tasks');
|
||||
const appengineTasks = require('./scripts/gulpfiles/appengine_tasks');
|
||||
const releaseTasks = require('./scripts/gulpfiles/release_tasks');
|
||||
const docsTasks = require('./scripts/gulpfiles/docs_tasks');
|
||||
const testTasks = require('./scripts/gulpfiles/test_tasks');
|
||||
|
||||
module.exports = {
|
||||
// Default target if gulp invoked without specifying.
|
||||
default: buildTasks.build,
|
||||
|
||||
// Main sequence targets. They already invoke prerequisites.
|
||||
langfiles: buildTasks.langfiles, // Build build/msg/*.js from msg/json/*.
|
||||
tsc: buildTasks.tsc,
|
||||
deps: buildTasks.deps,
|
||||
minify: buildTasks.minify,
|
||||
build: buildTasks.build,
|
||||
package: packageTasks.package,
|
||||
publish: releaseTasks.publish,
|
||||
publishBeta: releaseTasks.publishBeta,
|
||||
prepareDemos: appengineTasks.prepareDemos,
|
||||
deployDemos: appengineTasks.deployDemos,
|
||||
deployDemosBeta: appengineTasks.deployDemosBeta,
|
||||
gitUpdateGithubPages: gitTasks.updateGithubPages,
|
||||
|
||||
// Manually-invokable targets, with prerequisites where required.
|
||||
messages: buildTasks.messages, // Generate msg/json/en.json et al.
|
||||
clean: gulp.parallel(buildTasks.cleanBuildDir, packageTasks.cleanReleaseDir),
|
||||
test: testTasks.test,
|
||||
testGenerators: testTasks.generators,
|
||||
buildAdvancedCompilationTest: buildTasks.buildAdvancedCompilationTest,
|
||||
gitCreateRC: gitTasks.createRC,
|
||||
docs: docsTasks.docs,
|
||||
|
||||
// Legacy targets, to be deleted.
|
||||
recompile: releaseTasks.recompile,
|
||||
gitSyncDevelop: gitTasks.syncDevelop,
|
||||
gitSyncMaster: gitTasks.syncMaster,
|
||||
};
|
||||
95
gulpfile.mjs
Normal file
95
gulpfile.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Gulp script to build Blockly for Node & NPM.
|
||||
* Run this script by calling "npm install" in this directory.
|
||||
*/
|
||||
/* eslint-env node */
|
||||
|
||||
// Needed to prevent prettier from munging exports order, due to
|
||||
// https://github.com/simonhaenisch/prettier-plugin-organize-imports/issues/146
|
||||
// - but has the unfortunate side effect of suppressing ordering of
|
||||
// imports too:
|
||||
//
|
||||
// organize-imports-ignore
|
||||
|
||||
import {parallel} from 'gulp';
|
||||
import {
|
||||
deployDemos,
|
||||
deployDemosBeta,
|
||||
prepareDemos,
|
||||
} from './scripts/gulpfiles/appengine_tasks.mjs';
|
||||
import {
|
||||
build,
|
||||
buildAdvancedCompilationTest,
|
||||
cleanBuildDir,
|
||||
langfiles,
|
||||
messages,
|
||||
minify,
|
||||
tsc,
|
||||
} from './scripts/gulpfiles/build_tasks.mjs';
|
||||
import {docs} from './scripts/gulpfiles/docs_tasks.mjs';
|
||||
import {
|
||||
createRC,
|
||||
syncDevelop,
|
||||
syncMaster,
|
||||
updateGithubPages,
|
||||
} from './scripts/gulpfiles/git_tasks.mjs';
|
||||
import {cleanReleaseDir, pack} from './scripts/gulpfiles/package_tasks.mjs';
|
||||
import {
|
||||
publish,
|
||||
publishBeta,
|
||||
recompile,
|
||||
} from './scripts/gulpfiles/release_tasks.mjs';
|
||||
import {generators, test} from './scripts/gulpfiles/test_tasks.mjs';
|
||||
|
||||
const clean = parallel(cleanBuildDir, cleanReleaseDir);
|
||||
|
||||
// Default target if gulp invoked without specifying.
|
||||
export default build;
|
||||
|
||||
// Main sequence targets. They already invoke prerequisites. Listed
|
||||
// in typical order of invocation, and strictly listing prerequisites
|
||||
// before dependants.
|
||||
//
|
||||
// prettier-ignore
|
||||
export {
|
||||
langfiles,
|
||||
tsc,
|
||||
minify,
|
||||
build,
|
||||
pack, // Formerly package.
|
||||
publishBeta,
|
||||
publish,
|
||||
prepareDemos,
|
||||
deployDemosBeta,
|
||||
deployDemos,
|
||||
updateGithubPages as gitUpdateGithubPages,
|
||||
}
|
||||
|
||||
// Manually-invokable targets that also invoke prerequisites where
|
||||
// required.
|
||||
//
|
||||
// prettier-ignore
|
||||
export {
|
||||
messages, // Generate msg/json/en.json et al.
|
||||
clean,
|
||||
test,
|
||||
generators as testGenerators,
|
||||
buildAdvancedCompilationTest,
|
||||
createRC as gitCreateRC,
|
||||
docs,
|
||||
}
|
||||
|
||||
// Legacy targets, to be deleted.
|
||||
//
|
||||
// prettier-ignore
|
||||
export {
|
||||
recompile,
|
||||
syncDevelop as gitSyncDevelop,
|
||||
syncMaster as gitSyncMaster,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"@metadata": {
|
||||
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
|
||||
"lastupdated": "2025-04-21 10:42:10.549634",
|
||||
"lastupdated": "2025-06-17 15:36:41.845826",
|
||||
"locale": "en",
|
||||
"messagedocumentation" : "qqq"
|
||||
},
|
||||
@@ -398,22 +398,8 @@
|
||||
"COLLAPSED_WARNINGS_WARNING": "Collapsed blocks contain warnings.",
|
||||
"DIALOG_OK": "OK",
|
||||
"DIALOG_CANCEL": "Cancel",
|
||||
"DELETE_SHORTCUT": "Delete block (%1)",
|
||||
"DELETE_KEY": "Del",
|
||||
"EDIT_BLOCK_CONTENTS": "Edit Block contents (%1)",
|
||||
"INSERT_BLOCK": "Insert Block (%1)",
|
||||
"START_MOVE": "Start move",
|
||||
"FINISH_MOVE": "Finish move",
|
||||
"ABORT_MOVE": "Abort move",
|
||||
"MOVE_LEFT_CONSTRAINED": "Move left, constrained",
|
||||
"MOVE_RIGHT_CONSTRAINED": "Move right constrained",
|
||||
"MOVE_UP_CONSTRAINED": "Move up, constrained",
|
||||
"MOVE_DOWN_CONSTRAINED": "Move down constrained",
|
||||
"MOVE_LEFT_UNCONSTRAINED": "Move left, unconstrained",
|
||||
"MOVE_RIGHT_UNCONSTRAINED": "Move right, unconstrained",
|
||||
"MOVE_UP_UNCONSTRAINED": "Move up unconstrained",
|
||||
"MOVE_DOWN_UNCONSTRAINED": "Move down, unconstrained",
|
||||
"MOVE_BLOCK": "Move Block (%1)",
|
||||
"EDIT_BLOCK_CONTENTS": "Edit Block contents",
|
||||
"MOVE_BLOCK": "Move Block",
|
||||
"WINDOWS": "Windows",
|
||||
"MAC_OS": "macOS",
|
||||
"CHROME_OS": "ChromeOS",
|
||||
@@ -423,11 +409,15 @@
|
||||
"COMMAND_KEY": "⌘ Command",
|
||||
"OPTION_KEY": "⌥ Option",
|
||||
"ALT_KEY": "Alt",
|
||||
"CUT_SHORTCUT": "Cut (%1)",
|
||||
"COPY_SHORTCUT": "Copy (%1)",
|
||||
"PASTE_SHORTCUT": "Paste (%1)",
|
||||
"CUT_SHORTCUT": "Cut",
|
||||
"COPY_SHORTCUT": "Copy",
|
||||
"PASTE_SHORTCUT": "Paste",
|
||||
"HELP_PROMPT": "Press %1 for help on keyboard controls",
|
||||
"SHORTCUTS_GENERAL": "General",
|
||||
"SHORTCUTS_EDITING": "Editing",
|
||||
"SHORTCUTS_CODE_NAVIGATION": "Code navigation"
|
||||
"SHORTCUTS_CODE_NAVIGATION": "Code navigation",
|
||||
"KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position",
|
||||
"KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position",
|
||||
"KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.",
|
||||
"KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -1618,68 +1618,13 @@ Blockly.Msg.DIALOG_OK = 'OK';
|
||||
/// button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}
|
||||
Blockly.Msg.DIALOG_CANCEL = 'Cancel';
|
||||
|
||||
/** @type {string} */
|
||||
/// menu label - Contextual menu item that deletes the focused block.
|
||||
Blockly.Msg.DELETE_SHORTCUT = 'Delete block (%1)';
|
||||
/** @type {string} */
|
||||
/// menu label - Keyboard shortcut for the Delete key, shown at the end of a
|
||||
/// menu item that deletes the focused block.
|
||||
Blockly.Msg.DELETE_KEY = 'Del';
|
||||
/** @type {string} */
|
||||
/// menu label - Contextual menu item that moves the keyboard navigation cursor
|
||||
/// into a subitem of the focused block.
|
||||
Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents (%1)';
|
||||
/** @type {string} */
|
||||
/// menu label - Contextual menu item that prompts the user to choose a block to
|
||||
/// insert into the program at the focused location.
|
||||
Blockly.Msg.INSERT_BLOCK = 'Insert Block (%1)';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Contextual menu item that starts a keyboard-driven
|
||||
/// move of the focused block.
|
||||
Blockly.Msg.START_MOVE = 'Start move';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Contextual menu item that ends a keyboard-driven
|
||||
/// move of the focused block.
|
||||
Blockly.Msg.FINISH_MOVE = 'Finish move';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Contextual menu item that ends a keyboard-drive
|
||||
/// move of the focused block by returning it to its original location.
|
||||
Blockly.Msg.ABORT_MOVE = 'Abort move';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Description of shortcut that moves a block to the
|
||||
/// next valid location to the left.
|
||||
Blockly.Msg.MOVE_LEFT_CONSTRAINED = 'Move left, constrained';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Description of shortcut that moves a block to the
|
||||
/// next valid location to the right.
|
||||
Blockly.Msg.MOVE_RIGHT_CONSTRAINED = 'Move right constrained';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Description of shortcut that moves a block to the
|
||||
/// next valid location above it.
|
||||
Blockly.Msg.MOVE_UP_CONSTRAINED = 'Move up, constrained';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Description of shortcut that moves a block to the
|
||||
/// next valid location below it.
|
||||
Blockly.Msg.MOVE_DOWN_CONSTRAINED = 'Move down constrained';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Description of shortcut that moves a block freely
|
||||
/// to the left.
|
||||
Blockly.Msg.MOVE_LEFT_UNCONSTRAINED = 'Move left, unconstrained';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Description of shortcut that moves a block freely
|
||||
/// to the right.
|
||||
Blockly.Msg.MOVE_RIGHT_UNCONSTRAINED = 'Move right, unconstrained';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Description of shortcut that moves a block freely
|
||||
/// upwards.
|
||||
Blockly.Msg.MOVE_UP_UNCONSTRAINED = 'Move up unconstrained';
|
||||
/** @type {string} */
|
||||
/// keyboard shortcut label - Description of shortcut that moves a block freely
|
||||
/// downwards.
|
||||
Blockly.Msg.MOVE_DOWN_UNCONSTRAINED = 'Move down, unconstrained';
|
||||
Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents';
|
||||
/** @type {string} */
|
||||
/// menu label - Contextual menu item that starts a keyboard-driven block move.
|
||||
Blockly.Msg.MOVE_BLOCK = 'Move Block (%1)';
|
||||
Blockly.Msg.MOVE_BLOCK = 'Move Block';
|
||||
/** @type {string} */
|
||||
/// Name of the Microsoft Windows operating system displayed in a list of
|
||||
/// keyboard shortcuts.
|
||||
@@ -1714,13 +1659,13 @@ Blockly.Msg.OPTION_KEY = '⌥ Option';
|
||||
Blockly.Msg.ALT_KEY = 'Alt';
|
||||
/** @type {string} */
|
||||
/// menu label - Contextual menu item that cuts the focused item.
|
||||
Blockly.Msg.CUT_SHORTCUT = 'Cut (%1)';
|
||||
Blockly.Msg.CUT_SHORTCUT = 'Cut';
|
||||
/** @type {string} */
|
||||
/// menu label - Contextual menu item that copies the focused item.
|
||||
Blockly.Msg.COPY_SHORTCUT = 'Copy (%1)';
|
||||
Blockly.Msg.COPY_SHORTCUT = 'Copy';
|
||||
/** @type {string} */
|
||||
/// menu label - Contextual menu item that pastes the previously copied item.
|
||||
Blockly.Msg.PASTE_SHORTCUT = 'Paste (%1)';
|
||||
Blockly.Msg.PASTE_SHORTCUT = 'Paste';
|
||||
/** @type {string} */
|
||||
/// Alert message shown to prompt users to review available keyboard shortcuts.
|
||||
Blockly.Msg.HELP_PROMPT = 'Press %1 for help on keyboard controls';
|
||||
@@ -1735,3 +1680,16 @@ Blockly.Msg.SHORTCUTS_EDITING = 'Editing'
|
||||
/// shortcut list section header - Label for keyboard shortcuts related to
|
||||
/// moving around the workspace.
|
||||
Blockly.Msg.SHORTCUTS_CODE_NAVIGATION = 'Code navigation';
|
||||
/** @type {string} */
|
||||
/// Message shown to inform users how to move blocks to arbitrary locations
|
||||
/// with the keyboard.
|
||||
Blockly.Msg.KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT = 'Hold %1 and use arrow keys to move freely, then %2 to accept the position';
|
||||
/** @type {string} */
|
||||
/// Message shown to inform users how to move blocks with the keyboard.
|
||||
Blockly.Msg.KEYBOARD_NAV_CONSTRAINED_MOVE_HINT = 'Use the arrow keys to move, then %1 to accept the position';
|
||||
/** @type {string} */
|
||||
/// Message shown when an item is copied in keyboard navigation mode.
|
||||
Blockly.Msg.KEYBOARD_NAV_COPIED_HINT = 'Copied. Press %1 to paste.';
|
||||
/** @type {string} */
|
||||
/// Message shown when an item is cut in keyboard navigation mode.
|
||||
Blockly.Msg.KEYBOARD_NAV_CUT_HINT = 'Cut. Press %1 to paste.';
|
||||
1608
package-lock.json
generated
1608
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
25
scripts/gulpfiles/helper_tasks.mjs
Normal file
25
scripts/gulpfiles/helper_tasks.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2021 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Any gulp helper functions.
|
||||
*/
|
||||
|
||||
import Module from "node:module";
|
||||
|
||||
const require = Module.createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Load and return the contents of package.json.
|
||||
*
|
||||
* Uses require() rather than import, and clears the require cache, to
|
||||
* ensure the loaded package.json data is up to date.
|
||||
*/
|
||||
export function getPackageJson() {
|
||||
delete require.cache[require.resolve('../../package.json')];
|
||||
return require('../../package.json');
|
||||
}
|
||||
|
||||
@@ -8,20 +8,17 @@
|
||||
* @fileoverview Gulp tasks to package Blockly for distribution on NPM.
|
||||
*/
|
||||
|
||||
const gulp = require('gulp');
|
||||
gulp.concat = require('gulp-concat');
|
||||
gulp.replace = require('gulp-replace');
|
||||
gulp.rename = require('gulp-rename');
|
||||
gulp.insert = require('gulp-insert');
|
||||
gulp.umd = require('gulp-umd');
|
||||
gulp.replace = require('gulp-replace');
|
||||
import * as gulp from 'gulp';
|
||||
import concat from 'gulp-concat';
|
||||
import replace from 'gulp-replace';
|
||||
import umd from 'gulp-umd';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const {rimraf} = require('rimraf');
|
||||
const build = require('./build_tasks');
|
||||
const {getPackageJson} = require('./helper_tasks');
|
||||
const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} = require('./config');
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import {rimraf} from 'rimraf';
|
||||
import * as build from './build_tasks.mjs';
|
||||
import {getPackageJson} from './helper_tasks.mjs';
|
||||
import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} from './config.mjs';
|
||||
|
||||
// Path to template files for gulp-umd.
|
||||
const TEMPLATE_DIR = 'scripts/package/templates';
|
||||
@@ -32,7 +29,7 @@ const TEMPLATE_DIR = 'scripts/package/templates';
|
||||
* @param {Array<Object>} dependencies An array of dependencies to inject.
|
||||
*/
|
||||
function packageUMD(namespace, dependencies, template = 'umd.template') {
|
||||
return gulp.umd({
|
||||
return umd({
|
||||
dependencies: function () { return dependencies; },
|
||||
namespace: function () { return namespace; },
|
||||
exports: function () { return namespace; },
|
||||
@@ -88,7 +85,7 @@ function packageCoreNode() {
|
||||
function packageLocales() {
|
||||
// Remove references to goog.provide and goog.require.
|
||||
return gulp.src(`${LANG_BUILD_DIR}/*.js`)
|
||||
.pipe(gulp.replace(/goog\.[^\n]+/g, ''))
|
||||
.pipe(replace(/goog\.[^\n]+/g, ''))
|
||||
.pipe(packageUMD('Blockly.Msg', [], 'umd-msg.template'))
|
||||
.pipe(gulp.dest(`${RELEASE_DIR}/msg`));
|
||||
};
|
||||
@@ -107,7 +104,7 @@ function packageUMDBundle() {
|
||||
`${RELEASE_DIR}/javascript_compressed.js`,
|
||||
];
|
||||
return gulp.src(srcs)
|
||||
.pipe(gulp.concat('blockly.min.js'))
|
||||
.pipe(concat('blockly.min.js'))
|
||||
.pipe(gulp.dest(`${RELEASE_DIR}`));
|
||||
};
|
||||
|
||||
@@ -140,7 +137,7 @@ function packageUMDBundle() {
|
||||
* @param {Function} done Callback to call when done.
|
||||
*/
|
||||
function packageLegacyEntrypoints(done) {
|
||||
for (entrypoint of [
|
||||
for (const entrypoint of [
|
||||
'core', 'blocks', 'dart', 'javascript', 'lua', 'php', 'python'
|
||||
]) {
|
||||
const bundle =
|
||||
@@ -218,14 +215,14 @@ function packageDTS() {
|
||||
.pipe(gulp.src(`${TYPINGS_BUILD_DIR}/**/*.d.ts`, {ignore: [
|
||||
`${TYPINGS_BUILD_DIR}/blocks/**/*`,
|
||||
]}))
|
||||
.pipe(gulp.replace('AnyDuringMigration', 'any'))
|
||||
.pipe(replace('AnyDuringMigration', 'any'))
|
||||
.pipe(gulp.dest(RELEASE_DIR));
|
||||
};
|
||||
|
||||
/**
|
||||
* This task cleans the release directory (by deleting it).
|
||||
*/
|
||||
function cleanReleaseDir() {
|
||||
export function cleanReleaseDir() {
|
||||
// Sanity check.
|
||||
if (RELEASE_DIR === '.' || RELEASE_DIR === '/') {
|
||||
return Promise.reject(`Refusing to rm -rf ${RELEASE_DIR}`);
|
||||
@@ -237,9 +234,13 @@ function cleanReleaseDir() {
|
||||
* This task prepares the files to be included in the NPM by copying
|
||||
* them into the release directory.
|
||||
*
|
||||
* This task was formerly called "package" but was renamed in
|
||||
* preparation for porting gulpfiles to ESM because "package" is a
|
||||
* reserved word.
|
||||
*
|
||||
* Prerequisite: build.
|
||||
*/
|
||||
const package = gulp.series(
|
||||
export const pack = gulp.series(
|
||||
gulp.parallel(
|
||||
build.cleanBuildDir,
|
||||
cleanReleaseDir),
|
||||
@@ -254,9 +255,3 @@ const package = gulp.series(
|
||||
packageReadme,
|
||||
packageDTS)
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
// Main sequence targets. Each should invoke any immediate prerequisite(s).
|
||||
cleanReleaseDir: cleanReleaseDir,
|
||||
package: package,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
*/
|
||||
|
||||
import * as chai from 'chai';
|
||||
import {Key} from 'webdriverio';
|
||||
import {
|
||||
dragBlockTypeFromFlyout,
|
||||
getCategory,
|
||||
PAUSE_TIME,
|
||||
screenDirection,
|
||||
scrollFlyout,
|
||||
testFileLocations,
|
||||
testSetup,
|
||||
} from './test_setup.mjs';
|
||||
@@ -57,28 +58,29 @@ const testCategories = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Check whether an element is fully inside the bounds of the Blockly div. You can use this
|
||||
* to determine whether a block on the workspace or flyout is inside the Blockly div.
|
||||
* This does not check whether there are other Blockly elements (such as a toolbox or
|
||||
* flyout) on top of the element. A partially visible block is considered out of bounds.
|
||||
* Get the type of the nth block in the specified category.
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @param element The element to look for.
|
||||
* @returns A Promise resolving to true if the element is in bounds and false otherwise.
|
||||
* @param categoryName The name of the category to inspect.
|
||||
* @param n The index of the block to get
|
||||
* @returns A Promise resolving to the type the block in the specified
|
||||
* category's flyout at index i.
|
||||
*/
|
||||
async function elementInBounds(browser, element) {
|
||||
return await browser.execute((elem) => {
|
||||
const rect = elem.getBoundingClientRect();
|
||||
async function getNthBlockType(browser, categoryName, n) {
|
||||
const category = await getCategory(browser, categoryName);
|
||||
await category.click();
|
||||
await browser.pause(PAUSE_TIME);
|
||||
|
||||
const blocklyDiv = document.getElementById('blocklyDiv');
|
||||
const blocklyRect = blocklyDiv.getBoundingClientRect();
|
||||
const blockType = await browser.execute((i) => {
|
||||
return Blockly.getMainWorkspace()
|
||||
.getFlyout()
|
||||
.getWorkspace()
|
||||
.getTopBlocks(false)[i].type;
|
||||
}, n);
|
||||
|
||||
const vertInView =
|
||||
rect.top >= blocklyRect.top && rect.bottom <= blocklyRect.bottom;
|
||||
const horInView =
|
||||
rect.left >= blocklyRect.left && rect.right <= blocklyRect.right;
|
||||
|
||||
return vertInView && horInView;
|
||||
}, element);
|
||||
// Unicode escape to close flyout.
|
||||
await browser.keys([Key.Escape]);
|
||||
await browser.pause(PAUSE_TIME);
|
||||
return blockType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +103,7 @@ async function getBlockCount(browser, categoryName) {
|
||||
});
|
||||
|
||||
// Unicode escape to close flyout.
|
||||
await browser.keys(['\uE00C']);
|
||||
await browser.keys([Key.Escape]);
|
||||
await browser.pause(PAUSE_TIME);
|
||||
return blockCount;
|
||||
}
|
||||
@@ -141,18 +143,12 @@ async function openCategories(browser, categoryList, directionMultiplier) {
|
||||
await category.click();
|
||||
if (await isBlockDisabled(browser, i)) {
|
||||
// Unicode escape to close flyout.
|
||||
await browser.keys(['\uE00C']);
|
||||
await browser.keys([Key.Escape]);
|
||||
await browser.pause(PAUSE_TIME);
|
||||
continue;
|
||||
}
|
||||
const flyoutBlock = await browser.$(
|
||||
`.blocklyFlyout .blocklyBlockCanvas > g:nth-child(${3 + i * 2})`,
|
||||
);
|
||||
while (!(await elementInBounds(browser, flyoutBlock))) {
|
||||
await scrollFlyout(browser, 0, 50);
|
||||
}
|
||||
|
||||
await flyoutBlock.dragAndDrop({x: directionMultiplier * 50, y: 0});
|
||||
const blockType = await getNthBlockType(browser, categoryName, i);
|
||||
dragBlockTypeFromFlyout(browser, categoryName, blockType, 50, 20);
|
||||
await browser.pause(PAUSE_TIME);
|
||||
// Should be one top level block on the workspace.
|
||||
const topBlockCount = await browser.execute(() => {
|
||||
@@ -178,7 +174,9 @@ async function openCategories(browser, categoryList, directionMultiplier) {
|
||||
chai.assert.equal(failureCount, 0);
|
||||
}
|
||||
|
||||
suite('Open toolbox categories', function () {
|
||||
// TODO (#9217) These take too long to run and are very flakey. Need to find a
|
||||
// better way to test whatever this is trying to test.
|
||||
suite.skip('Open toolbox categories', function () {
|
||||
this.timeout(0);
|
||||
|
||||
test('opening every toolbox category in the category toolbox in LTR', async function () {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ suite('Clipboard', function () {
|
||||
await mutatorIcon.setBubbleVisible(true);
|
||||
const mutatorWorkspace = mutatorIcon.getWorkspace();
|
||||
const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0];
|
||||
assert.notEqual(elseIf, undefined);
|
||||
assert.isDefined(elseIf);
|
||||
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
|
||||
assert.lengthOf(this.workspace.getAllBlocks(), 1);
|
||||
const data = elseIf.toCopyData();
|
||||
@@ -85,6 +85,34 @@ suite('Clipboard', function () {
|
||||
assert.lengthOf(this.workspace.getAllBlocks(), 1);
|
||||
});
|
||||
|
||||
test('pasting into a mutator flyout pastes into the mutator workspace', async function () {
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
{
|
||||
'type': 'controls_if',
|
||||
'id': 'blockId',
|
||||
'extraState': {
|
||||
'elseIfCount': 1,
|
||||
},
|
||||
},
|
||||
this.workspace,
|
||||
);
|
||||
const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR);
|
||||
await mutatorIcon.setBubbleVisible(true);
|
||||
const mutatorWorkspace = mutatorIcon.getWorkspace();
|
||||
const mutatorFlyoutWorkspace = mutatorWorkspace
|
||||
.getFlyout()
|
||||
.getWorkspace();
|
||||
const elseIf =
|
||||
mutatorFlyoutWorkspace.getBlocksByType('controls_if_elseif')[0];
|
||||
assert.isDefined(elseIf);
|
||||
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
|
||||
assert.lengthOf(this.workspace.getAllBlocks(), 1);
|
||||
const data = elseIf.toCopyData();
|
||||
Blockly.clipboard.paste(data, mutatorFlyoutWorkspace);
|
||||
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3);
|
||||
assert.lengthOf(this.workspace.getAllBlocks(), 1);
|
||||
});
|
||||
|
||||
suite('pasted blocks are placed in unambiguous locations', function () {
|
||||
test('pasted blocks are bumped to not overlap', function () {
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
@@ -139,8 +167,7 @@ suite('Clipboard', function () {
|
||||
});
|
||||
|
||||
suite('pasting comments', function () {
|
||||
// TODO: Reenable test when we readd copy-paste.
|
||||
test.skip('pasted comments are bumped to not overlap', function () {
|
||||
test('pasted comments are bumped to not overlap', function () {
|
||||
Blockly.Xml.domToWorkspace(
|
||||
Blockly.utils.xml.textToDom(
|
||||
'<xml><comment id="test" x=10 y=10/></xml>',
|
||||
@@ -153,7 +180,7 @@ suite('Clipboard', function () {
|
||||
const newComment = Blockly.clipboard.paste(data, this.workspace);
|
||||
assert.deepEqual(
|
||||
newComment.getRelativeToSurfaceXY(),
|
||||
new Blockly.utils.Coordinate(60, 60),
|
||||
new Blockly.utils.Coordinate(40, 40),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user