mirror of
https://github.com/google/blockly.git
synced 2026-01-08 01:20:12 +01:00
Merge pull request #9335 from google/rc/v12.3.0
release: Merge `rc/v12.3.0` into `master`.
This commit is contained in:
6
.github/workflows/appengine_deploy.yml
vendored
6
.github/workflows/appengine_deploy.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
# Checks-out the repository under $GITHUB_WORKSPACE.
|
||||
# When running manually this checks out the master branch.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare demo files
|
||||
# Install all dependencies, then copy all the files needed for demos.
|
||||
@@ -36,13 +36,13 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Download prepared files
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: appengine_files
|
||||
path: _deploy/
|
||||
|
||||
- name: Deploy to App Engine
|
||||
uses: google-github-actions/deploy-appengine@v2.1.5
|
||||
uses: google-github-actions/deploy-appengine@v2.1.7
|
||||
# For parameters see:
|
||||
# https://github.com/google-github-actions/deploy-appengine#inputs
|
||||
with:
|
||||
|
||||
6
.github/workflows/browser_test.yml
vendored
6
.github/workflows/browser_test.yml
vendored
@@ -5,13 +5,15 @@ name: Run browser manually
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Runs every Monday at 06:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 120
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
@@ -24,7 +26,7 @@ jobs:
|
||||
# https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -18,12 +18,12 @@ jobs:
|
||||
# TODO (#2114): re-enable osx build.
|
||||
# os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest]
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
node-version: [18.x, 20.x, 22.x, 24.x]
|
||||
# See supported Node.js release schedule at
|
||||
# https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
4
.github/workflows/keyboard_plugin_test.yml
vendored
4
.github/workflows/keyboard_plugin_test.yml
vendored
@@ -25,12 +25,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout core Blockly
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
path: core-blockly
|
||||
|
||||
- name: Checkout keyboard navigation plugin
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: 'google/blockly-keyboard-experimentation'
|
||||
ref: 'main'
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
- uses: actions/first-interaction@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-message: >
|
||||
|
||||
@@ -501,22 +501,32 @@ export class Block {
|
||||
// Detach this block from the parent's tree.
|
||||
this.previousConnection.disconnect();
|
||||
}
|
||||
const nextBlock = this.getNextBlock();
|
||||
if (opt_healStack && nextBlock && !nextBlock.isShadow()) {
|
||||
// Disconnect the next statement.
|
||||
const nextTarget = this.nextConnection?.targetConnection ?? null;
|
||||
nextTarget?.disconnect();
|
||||
if (
|
||||
previousTarget &&
|
||||
this.workspace.connectionChecker.canConnect(
|
||||
previousTarget,
|
||||
nextTarget,
|
||||
false,
|
||||
)
|
||||
) {
|
||||
// Attach the next statement to the previous statement.
|
||||
previousTarget.connect(nextTarget!);
|
||||
}
|
||||
|
||||
if (!opt_healStack) return;
|
||||
|
||||
// Immovable or shadow next blocks need to move along with the block; keep
|
||||
// going until we encounter a normal block or run off the end of the stack.
|
||||
let nextBlock = this.getNextBlock();
|
||||
while (nextBlock && (nextBlock.isShadow() || !nextBlock.isMovable())) {
|
||||
nextBlock = nextBlock.getNextBlock();
|
||||
}
|
||||
if (!nextBlock) return;
|
||||
|
||||
// Disconnect the next statement.
|
||||
const nextTarget =
|
||||
nextBlock.previousConnection?.targetBlock()?.nextConnection
|
||||
?.targetConnection ?? null;
|
||||
nextTarget?.disconnect();
|
||||
if (
|
||||
previousTarget &&
|
||||
this.workspace.connectionChecker.canConnect(
|
||||
previousTarget,
|
||||
nextTarget,
|
||||
false,
|
||||
)
|
||||
) {
|
||||
// Attach the next statement to the previous statement.
|
||||
previousTarget.connect(nextTarget!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1116,9 +1126,9 @@ export class Block {
|
||||
/**
|
||||
* Returns a generator that provides every field on the block.
|
||||
*
|
||||
* @yields A generator that can be used to iterate the fields on the block.
|
||||
* @returns A generator that can be used to iterate the fields on the block.
|
||||
*/
|
||||
*getFields(): Generator<Field> {
|
||||
*getFields(): Generator<Field, undefined, void> {
|
||||
for (const input of this.inputList) {
|
||||
for (const field of input.fieldRow) {
|
||||
yield field;
|
||||
|
||||
@@ -951,9 +951,12 @@ export class BlockSvg
|
||||
/**
|
||||
* Encode a block for copying.
|
||||
*
|
||||
* @param addNextBlocks If true, copy subsequent blocks attached to this one
|
||||
* as well.
|
||||
*
|
||||
* @returns Copy metadata, or null if the block is an insertion marker.
|
||||
*/
|
||||
toCopyData(): BlockCopyData | null {
|
||||
toCopyData(addNextBlocks = false): BlockCopyData | null {
|
||||
if (this.isInsertionMarker_) {
|
||||
return null;
|
||||
}
|
||||
@@ -961,7 +964,8 @@ export class BlockSvg
|
||||
paster: BlockPaster.TYPE,
|
||||
blockState: blocks.save(this, {
|
||||
addCoordinates: true,
|
||||
addNextBlocks: false,
|
||||
addNextBlocks,
|
||||
saveIds: false,
|
||||
}) as blocks.State,
|
||||
typeCounts: common.getBlockTypeCounts(this, true),
|
||||
};
|
||||
@@ -1840,6 +1844,9 @@ export class BlockSvg
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {
|
||||
this.select();
|
||||
this.workspace.scrollBoundsIntoView(
|
||||
this.getBoundingRectangleWithoutChildren(),
|
||||
);
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
|
||||
@@ -9,7 +9,9 @@ import * as common from '../common.js';
|
||||
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {IBubble} from '../interfaces/i_bubble.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import {ISelectable} from '../interfaces/i_selectable.js';
|
||||
import {ContainerRegion} from '../metrics_manager.js';
|
||||
import {Scrollbar} from '../scrollbar.js';
|
||||
@@ -27,7 +29,7 @@ import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
* bubble, where it has a "tail" that points to the block, and a "head" that
|
||||
* displays arbitrary svg elements.
|
||||
*/
|
||||
export abstract class Bubble implements IBubble, ISelectable {
|
||||
export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
|
||||
/** The width of the border around the bubble. */
|
||||
static readonly BORDER_WIDTH = 6;
|
||||
|
||||
@@ -100,12 +102,14 @@ export abstract class Bubble implements IBubble, ISelectable {
|
||||
* element that's represented by this bubble (as a focusable node). This
|
||||
* element will have its ID overwritten. If not provided, the focusable
|
||||
* element of this node will default to the bubble's SVG root.
|
||||
* @param owner The object responsible for hosting/spawning this bubble.
|
||||
*/
|
||||
constructor(
|
||||
public readonly workspace: WorkspaceSvg,
|
||||
protected anchor: Coordinate,
|
||||
protected ownerRect?: Rect,
|
||||
overriddenFocusableElement?: SVGElement | HTMLElement,
|
||||
protected owner?: IHasBubble & IFocusableNode,
|
||||
) {
|
||||
this.id = idGenerator.getNextUniqueId();
|
||||
this.svgRoot = dom.createSvgElement(
|
||||
@@ -145,6 +149,13 @@ export abstract class Bubble implements IBubble, ISelectable {
|
||||
this,
|
||||
this.onMouseDown,
|
||||
);
|
||||
|
||||
browserEvents.conditionalBind(
|
||||
this.focusableElement,
|
||||
'keydown',
|
||||
this,
|
||||
this.onKeyDown,
|
||||
);
|
||||
}
|
||||
|
||||
/** Dispose of this bubble. */
|
||||
@@ -229,6 +240,19 @@ export abstract class Bubble implements IBubble, ISelectable {
|
||||
getFocusManager().focusNode(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles key events when this bubble is focused. By default, closes the
|
||||
* bubble on Escape.
|
||||
*
|
||||
* @param e The keyboard event to handle.
|
||||
*/
|
||||
protected onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && this.owner) {
|
||||
this.owner.setBubbleVisible(false);
|
||||
getFocusManager().focusNode(this.owner);
|
||||
}
|
||||
}
|
||||
|
||||
/** Positions the bubble relative to its anchor. Does not render its tail. */
|
||||
protected positionRelativeToAnchor() {
|
||||
let left = this.anchor.x;
|
||||
@@ -683,6 +707,10 @@ export abstract class Bubble implements IBubble, ISelectable {
|
||||
onNodeFocus(): void {
|
||||
this.select();
|
||||
this.bringToFront();
|
||||
const xy = this.getRelativeToSurfaceXY();
|
||||
const size = this.getSize();
|
||||
const bounds = new Rect(xy.y, xy.y + size.height, xy.x, xy.x + size.width);
|
||||
this.workspace.scrollBoundsIntoView(bounds);
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
@@ -694,4 +722,11 @@ export abstract class Bubble implements IBubble, ISelectable {
|
||||
canBeFocused(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the object that owns/hosts this bubble, if any.
|
||||
*/
|
||||
getOwner(): (IHasBubble & IFocusableNode) | undefined {
|
||||
return this.owner;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {CommentEditor} from '../comments/comment_editor.js';
|
||||
import * as Css from '../css.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import * as touch from '../touch.js';
|
||||
import {browserEvents} from '../utils.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
@@ -21,12 +25,6 @@ import {Bubble} from './bubble.js';
|
||||
* Used by the comment icon.
|
||||
*/
|
||||
export class TextInputBubble extends Bubble {
|
||||
/** The root of the elements specific to the text element. */
|
||||
private inputRoot: SVGForeignObjectElement;
|
||||
|
||||
/** The text input area element. */
|
||||
private textArea: HTMLTextAreaElement;
|
||||
|
||||
/** The group containing the lines indicating the bubble is resizable. */
|
||||
private resizeGroup: SVGGElement;
|
||||
|
||||
@@ -42,18 +40,12 @@ export class TextInputBubble extends Bubble {
|
||||
*/
|
||||
private resizePointerMoveListener: browserEvents.Data | null = null;
|
||||
|
||||
/** Functions listening for changes to the text of this bubble. */
|
||||
private textChangeListeners: (() => void)[] = [];
|
||||
|
||||
/** Functions listening for changes to the size of this bubble. */
|
||||
private sizeChangeListeners: (() => void)[] = [];
|
||||
|
||||
/** Functions listening for changes to the location of this bubble. */
|
||||
private locationChangeListeners: (() => void)[] = [];
|
||||
|
||||
/** The text of this bubble. */
|
||||
private text = '';
|
||||
|
||||
/** The default size of this bubble, including borders. */
|
||||
private readonly DEFAULT_SIZE = new Size(
|
||||
160 + Bubble.DOUBLE_BORDER,
|
||||
@@ -68,46 +60,47 @@ export class TextInputBubble extends Bubble {
|
||||
|
||||
private editable = true;
|
||||
|
||||
/** View responsible for supporting text editing. */
|
||||
private editor: CommentEditor;
|
||||
|
||||
/**
|
||||
* @param workspace The workspace this bubble belongs to.
|
||||
* @param anchor The anchor location of the thing this bubble is attached to.
|
||||
* The tail of the bubble will point to this location.
|
||||
* @param ownerRect An optional rect we don't want the bubble to overlap with
|
||||
* when automatically positioning.
|
||||
* @param owner The object that owns/hosts this bubble.
|
||||
*/
|
||||
constructor(
|
||||
public readonly workspace: WorkspaceSvg,
|
||||
protected anchor: Coordinate,
|
||||
protected ownerRect?: Rect,
|
||||
protected owner?: IHasBubble & IFocusableNode,
|
||||
) {
|
||||
super(workspace, anchor, ownerRect, TextInputBubble.createTextArea());
|
||||
super(workspace, anchor, ownerRect, undefined, owner);
|
||||
dom.addClass(this.svgRoot, 'blocklyTextInputBubble');
|
||||
this.textArea = this.getFocusableElement() as HTMLTextAreaElement;
|
||||
this.inputRoot = this.createEditor(this.contentContainer, this.textArea);
|
||||
this.editor = new CommentEditor(workspace, this.id, () => {
|
||||
getFocusManager().focusNode(this);
|
||||
});
|
||||
this.contentContainer.appendChild(this.editor.getDom());
|
||||
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
|
||||
this.setSize(this.DEFAULT_SIZE, true);
|
||||
}
|
||||
|
||||
/** @returns the text of this bubble. */
|
||||
getText(): string {
|
||||
return this.text;
|
||||
return this.editor.getText();
|
||||
}
|
||||
|
||||
/** Sets the text of this bubble. Calls change listeners. */
|
||||
setText(text: string) {
|
||||
this.text = text;
|
||||
this.textArea.value = text;
|
||||
this.onTextChange();
|
||||
this.editor.setText(text);
|
||||
}
|
||||
|
||||
/** Sets whether or not the text in the bubble is editable. */
|
||||
setEditable(editable: boolean) {
|
||||
this.editable = editable;
|
||||
if (this.editable) {
|
||||
this.textArea.removeAttribute('readonly');
|
||||
} else {
|
||||
this.textArea.setAttribute('readonly', '');
|
||||
}
|
||||
this.editor.setEditable(editable);
|
||||
}
|
||||
|
||||
/** Returns whether or not the text in the bubble is editable. */
|
||||
@@ -117,7 +110,7 @@ export class TextInputBubble extends Bubble {
|
||||
|
||||
/** Adds a change listener to be notified when this bubble's text changes. */
|
||||
addTextChangeListener(listener: () => void) {
|
||||
this.textChangeListeners.push(listener);
|
||||
this.editor.addTextChangeListener(listener);
|
||||
}
|
||||
|
||||
/** Adds a change listener to be notified when this bubble's size changes. */
|
||||
@@ -130,58 +123,6 @@ export class TextInputBubble extends Bubble {
|
||||
this.locationChangeListeners.push(listener);
|
||||
}
|
||||
|
||||
/** Creates and returns the editable text area for this bubble's editor. */
|
||||
private static createTextArea(): HTMLTextAreaElement {
|
||||
const textArea = document.createElementNS(
|
||||
dom.HTML_NS,
|
||||
'textarea',
|
||||
) as HTMLTextAreaElement;
|
||||
textArea.className = 'blocklyTextarea blocklyText';
|
||||
return textArea;
|
||||
}
|
||||
|
||||
/** Creates and returns the UI container element for this bubble's editor. */
|
||||
private createEditor(
|
||||
container: SVGGElement,
|
||||
textArea: HTMLTextAreaElement,
|
||||
): SVGForeignObjectElement {
|
||||
const inputRoot = dom.createSvgElement(
|
||||
Svg.FOREIGNOBJECT,
|
||||
{
|
||||
'x': Bubble.BORDER_WIDTH,
|
||||
'y': Bubble.BORDER_WIDTH,
|
||||
},
|
||||
container,
|
||||
);
|
||||
|
||||
const body = document.createElementNS(dom.HTML_NS, 'body');
|
||||
body.setAttribute('xmlns', dom.HTML_NS);
|
||||
body.className = 'blocklyMinimalBody';
|
||||
|
||||
textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
|
||||
body.appendChild(textArea);
|
||||
inputRoot.appendChild(body);
|
||||
|
||||
this.bindTextAreaEvents(textArea);
|
||||
|
||||
return inputRoot;
|
||||
}
|
||||
|
||||
/** Binds events to the text area element. */
|
||||
private bindTextAreaEvents(textArea: HTMLTextAreaElement) {
|
||||
// Don't zoom with mousewheel; let it scroll instead.
|
||||
browserEvents.conditionalBind(textArea, 'wheel', this, (e: Event) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
// Don't let the pointerdown event get to the workspace.
|
||||
browserEvents.conditionalBind(textArea, 'pointerdown', this, (e: Event) => {
|
||||
e.stopPropagation();
|
||||
touch.clearTouchIdentifier();
|
||||
});
|
||||
|
||||
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
|
||||
}
|
||||
|
||||
/** Creates the resize handler elements and binds events to them. */
|
||||
private createResizeHandle(
|
||||
container: SVGGElement,
|
||||
@@ -220,8 +161,12 @@ export class TextInputBubble extends Bubble {
|
||||
|
||||
const widthMinusBorder = size.width - Bubble.DOUBLE_BORDER;
|
||||
const heightMinusBorder = size.height - Bubble.DOUBLE_BORDER;
|
||||
this.inputRoot.setAttribute('width', `${widthMinusBorder}`);
|
||||
this.inputRoot.setAttribute('height', `${heightMinusBorder}`);
|
||||
this.editor.updateSize(
|
||||
new Size(widthMinusBorder, heightMinusBorder),
|
||||
new Size(0, 0),
|
||||
);
|
||||
this.editor.getDom().setAttribute('x', `${Bubble.DOUBLE_BORDER / 2}`);
|
||||
this.editor.getDom().setAttribute('y', `${Bubble.DOUBLE_BORDER / 2}`);
|
||||
|
||||
this.resizeGroup.setAttribute('y', `${heightMinusBorder}`);
|
||||
if (this.workspace.RTL) {
|
||||
@@ -312,14 +257,6 @@ export class TextInputBubble extends Bubble {
|
||||
this.onSizeChange();
|
||||
}
|
||||
|
||||
/** Handles a text change event for the text area. Calls event listeners. */
|
||||
private onTextChange() {
|
||||
this.text = this.textArea.value;
|
||||
for (const listener of this.textChangeListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles a size change event for the text area. Calls event listeners. */
|
||||
private onSizeChange() {
|
||||
for (const listener of this.sizeChangeListeners) {
|
||||
@@ -333,6 +270,15 @@ export class TextInputBubble extends Bubble {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text editor component of this bubble.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getEditor() {
|
||||
return this.editor;
|
||||
}
|
||||
}
|
||||
|
||||
Css.register(`
|
||||
|
||||
@@ -83,6 +83,9 @@ export function moveBlockToNotConflict(
|
||||
block: BlockSvg,
|
||||
originalPosition: Coordinate,
|
||||
) {
|
||||
if (block.workspace.RTL) {
|
||||
originalPosition.x = block.workspace.getWidth() - originalPosition.x;
|
||||
}
|
||||
const workspace = block.workspace;
|
||||
const snapRadius = config.snapRadius;
|
||||
const bumpOffset = Coordinate.difference(
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {CommentBarButton} from './comment_bar_button.js';
|
||||
import type {CommentView} from './comment_view.js';
|
||||
|
||||
/**
|
||||
* Magic string appended to the comment ID to create a unique ID for this button.
|
||||
@@ -42,8 +43,9 @@ export class CollapseCommentBarButton extends CommentBarButton {
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
protected readonly commentView: CommentView,
|
||||
) {
|
||||
super(id, workspace, container);
|
||||
super(id, workspace, container, commentView);
|
||||
|
||||
this.icon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
@@ -86,14 +88,13 @@ export class CollapseCommentBarButton extends CommentBarButton {
|
||||
override performAction(e?: Event) {
|
||||
touch.clearTouchIdentifier();
|
||||
|
||||
const comment = this.getParentComment();
|
||||
comment.view.bringToFront();
|
||||
this.getCommentView().bringToFront();
|
||||
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
comment.setCollapsed(!comment.isCollapsed());
|
||||
this.getCommentView().setCollapsed(!this.getCommentView().isCollapsed());
|
||||
this.workspace.hideChaff();
|
||||
|
||||
e?.stopPropagation();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {Rect} from '../utils/rect.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
|
||||
import type {CommentView} from './comment_view.js';
|
||||
|
||||
/**
|
||||
* Button displayed on a comment's top bar.
|
||||
@@ -29,6 +29,7 @@ export abstract class CommentBarButton implements IFocusableNode {
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
protected readonly commentView: CommentView,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -39,17 +40,10 @@ export abstract class CommentBarButton implements IFocusableNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent comment of this comment bar button.
|
||||
* Returns the parent comment view of this comment bar button.
|
||||
*/
|
||||
getParentComment(): RenderedWorkspaceComment {
|
||||
const comment = this.workspace.getCommentById(this.id);
|
||||
if (!comment) {
|
||||
throw new Error(
|
||||
`Comment bar button ${this.id} has no corresponding comment`,
|
||||
);
|
||||
}
|
||||
|
||||
return comment;
|
||||
getCommentView(): CommentView {
|
||||
return this.commentView;
|
||||
}
|
||||
|
||||
/** Adjusts the position of this button within its parent container. */
|
||||
@@ -93,7 +87,13 @@ export abstract class CommentBarButton implements IFocusableNode {
|
||||
}
|
||||
|
||||
/** Called when this button's focusable DOM element gains focus. */
|
||||
onNodeFocus() {}
|
||||
onNodeFocus() {
|
||||
const commentView = this.getCommentView();
|
||||
const xy = commentView.getRelativeToSurfaceXY();
|
||||
const size = commentView.getSize();
|
||||
const bounds = new Rect(xy.y, xy.y + size.height, xy.x, xy.x + size.width);
|
||||
commentView.workspace.scrollBoundsIntoView(bounds);
|
||||
}
|
||||
|
||||
/** Called when this button's focusable DOM element loses focus. */
|
||||
onNodeBlur() {}
|
||||
|
||||
@@ -10,8 +10,10 @@ 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 {Rect} from '../utils/rect.js';
|
||||
import {Size} from '../utils/size.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import * as svgMath from '../utils/svg_math.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,7 @@ export class CommentEditor implements IFocusableNode {
|
||||
'textarea',
|
||||
) as HTMLTextAreaElement;
|
||||
this.textArea.setAttribute('tabindex', '-1');
|
||||
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
|
||||
dom.addClass(this.textArea, 'blocklyCommentText');
|
||||
dom.addClass(this.textArea, 'blocklyTextarea');
|
||||
dom.addClass(this.textArea, 'blocklyText');
|
||||
@@ -86,6 +89,11 @@ export class CommentEditor implements IFocusableNode {
|
||||
},
|
||||
);
|
||||
|
||||
// Don't zoom with mousewheel; let it scroll instead.
|
||||
browserEvents.conditionalBind(this.textArea, 'wheel', this, (e: Event) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Register listener for keydown events that would finish editing.
|
||||
browserEvents.conditionalBind(
|
||||
this.textArea,
|
||||
@@ -182,7 +190,16 @@ export class CommentEditor implements IFocusableNode {
|
||||
getFocusableTree(): IFocusableTree {
|
||||
return this.workspace;
|
||||
}
|
||||
onNodeFocus(): void {}
|
||||
onNodeFocus(): void {
|
||||
const bbox = Rect.from(this.foreignObject.getBoundingClientRect());
|
||||
this.workspace.scrollBoundsIntoView(
|
||||
Rect.createFromPoint(
|
||||
svgMath.screenToWsCoordinates(this.workspace, bbox.getOrigin()),
|
||||
bbox.getWidth(),
|
||||
bbox.getHeight(),
|
||||
),
|
||||
);
|
||||
}
|
||||
onNodeBlur(): void {}
|
||||
canBeFocused(): boolean {
|
||||
if (this.id) return true;
|
||||
|
||||
@@ -102,7 +102,7 @@ export class CommentView implements IRenderedElement {
|
||||
|
||||
constructor(
|
||||
readonly workspace: WorkspaceSvg,
|
||||
private commentId: string,
|
||||
readonly commentId: string,
|
||||
) {
|
||||
this.svgRoot = dom.createSvgElement(Svg.G, {
|
||||
'class': 'blocklyComment blocklyEditable blocklyDraggable',
|
||||
@@ -176,12 +176,18 @@ export class CommentView implements IRenderedElement {
|
||||
this.commentId,
|
||||
this.workspace,
|
||||
topBarGroup,
|
||||
this,
|
||||
);
|
||||
const foldoutButton = new CollapseCommentBarButton(
|
||||
this.commentId,
|
||||
this.workspace,
|
||||
topBarGroup,
|
||||
this,
|
||||
);
|
||||
this.addDisposeListener(() => {
|
||||
deleteButton.dispose();
|
||||
foldoutButton.dispose();
|
||||
});
|
||||
const textPreview = dom.createSvgElement(
|
||||
Svg.TEXT,
|
||||
{
|
||||
@@ -362,7 +368,10 @@ export class CommentView implements IRenderedElement {
|
||||
|
||||
const textPreviewWidth =
|
||||
size.width - foldoutSize.getWidth() - deleteSize.getWidth();
|
||||
this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`);
|
||||
this.textPreview.setAttribute(
|
||||
'x',
|
||||
`${(this.workspace.RTL ? -1 : 1) * foldoutSize.getWidth()}`,
|
||||
);
|
||||
this.textPreview.setAttribute(
|
||||
'y',
|
||||
`${textPreviewMargin + textPreviewSize.height / 2}`,
|
||||
@@ -612,13 +621,12 @@ export class CommentView implements IRenderedElement {
|
||||
/** Disposes of this comment view. */
|
||||
dispose() {
|
||||
this.disposing = true;
|
||||
this.foldoutButton.dispose();
|
||||
this.deleteButton.dispose();
|
||||
dom.removeNode(this.svgRoot);
|
||||
// Loop through listeners backwards in case they remove themselves.
|
||||
for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
|
||||
this.disposeListeners[i]();
|
||||
}
|
||||
this.disposeListeners.length = 0;
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {CommentBarButton} from './comment_bar_button.js';
|
||||
import type {CommentView} from './comment_view.js';
|
||||
|
||||
/**
|
||||
* Magic string appended to the comment ID to create a unique ID for this button.
|
||||
@@ -42,8 +43,9 @@ export class DeleteCommentBarButton extends CommentBarButton {
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
protected readonly commentView: CommentView,
|
||||
) {
|
||||
super(id, workspace, container);
|
||||
super(id, workspace, container, commentView);
|
||||
|
||||
this.icon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
@@ -97,7 +99,7 @@ export class DeleteCommentBarButton extends CommentBarButton {
|
||||
return;
|
||||
}
|
||||
|
||||
this.getParentComment().dispose();
|
||||
this.getCommentView().dispose();
|
||||
e?.stopPropagation();
|
||||
getFocusManager().focusNode(this.workspace);
|
||||
}
|
||||
|
||||
@@ -74,15 +74,6 @@ export class RenderedWorkspaceComment
|
||||
this,
|
||||
this.startGesture,
|
||||
);
|
||||
// Don't zoom with mousewheel; let it scroll instead.
|
||||
browserEvents.conditionalBind(
|
||||
this.view.getSvgRoot(),
|
||||
'wheel',
|
||||
this,
|
||||
(e: Event) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,6 +280,7 @@ export class RenderedWorkspaceComment
|
||||
paster: WorkspaceCommentPaster.TYPE,
|
||||
commentState: commentSerialization.save(this, {
|
||||
addCoordinates: true,
|
||||
saveIds: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -355,6 +347,7 @@ export class RenderedWorkspaceComment
|
||||
this.select();
|
||||
// Ensure that the comment is always at the top when focused.
|
||||
this.workspace.getLayerManager()?.append(this, layers.BLOCK);
|
||||
this.workspace.scrollBoundsIntoView(this.getBoundingRectangle());
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
|
||||
@@ -23,8 +23,15 @@ import {CommentIcon} from './icons/comment_icon.js';
|
||||
import {Msg} from './msg.js';
|
||||
import {StatementInput} from './renderers/zelos/zelos.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as svgMath from './utils/svg_math.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
function isFullBlockField(block?: BlockSvg) {
|
||||
if (!block || !block.isSimpleReporter()) return false;
|
||||
const firstField = block.getFields().next().value;
|
||||
return firstField?.isFullBlockField();
|
||||
}
|
||||
|
||||
/**
|
||||
* Option to undo previous action.
|
||||
*/
|
||||
@@ -362,10 +369,15 @@ export function registerComment() {
|
||||
preconditionFn(scope: Scope) {
|
||||
const block = scope.block;
|
||||
if (
|
||||
!block!.isInFlyout &&
|
||||
block!.workspace.options.comments &&
|
||||
!block!.isCollapsed() &&
|
||||
block!.isEditable()
|
||||
block &&
|
||||
!block.isInFlyout &&
|
||||
block.workspace.options.comments &&
|
||||
!block.isCollapsed() &&
|
||||
block.isEditable() &&
|
||||
// Either block already has a comment so let us remove it,
|
||||
// or the block isn't just one full-block field block, which
|
||||
// shouldn't be allowed to have comments as there's no way to read them.
|
||||
(block.hasIcon(CommentIcon.TYPE) || !isFullBlockField(block))
|
||||
) {
|
||||
return 'enabled';
|
||||
}
|
||||
@@ -373,8 +385,8 @@ export function registerComment() {
|
||||
},
|
||||
callback(scope: Scope) {
|
||||
const block = scope.block;
|
||||
if (block!.hasIcon(CommentIcon.TYPE)) {
|
||||
block!.setCommentText(null);
|
||||
if (block && block.hasIcon(CommentIcon.TYPE)) {
|
||||
block.setCommentText(null);
|
||||
} else {
|
||||
block!.setCommentText('');
|
||||
}
|
||||
@@ -626,9 +638,9 @@ export function registerCommentCreate() {
|
||||
const comment = new RenderedWorkspaceComment(workspace);
|
||||
comment.setPlaceholderText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']);
|
||||
comment.moveTo(
|
||||
pixelsToWorkspaceCoords(
|
||||
new Coordinate(location.x, location.y),
|
||||
svgMath.screenToWsCoordinates(
|
||||
workspace,
|
||||
new Coordinate(location.x, location.y),
|
||||
),
|
||||
);
|
||||
getFocusManager().focusNode(comment);
|
||||
@@ -641,40 +653,6 @@ export function registerCommentCreate() {
|
||||
ContextMenuRegistry.registry.register(createOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts pixel coordinates (relative to the window) to workspace coordinates.
|
||||
*/
|
||||
function pixelsToWorkspaceCoords(
|
||||
pixelCoord: Coordinate,
|
||||
workspace: WorkspaceSvg,
|
||||
): Coordinate {
|
||||
const injectionDiv = workspace.getInjectionDiv();
|
||||
// Bounding rect coordinates are in client coordinates, meaning that they
|
||||
// are in pixels relative to the upper left corner of the visible browser
|
||||
// window. These coordinates change when you scroll the browser window.
|
||||
const boundingRect = injectionDiv.getBoundingClientRect();
|
||||
|
||||
// The client coordinates offset by the injection div's upper left corner.
|
||||
const clientOffsetPixels = new Coordinate(
|
||||
pixelCoord.x - boundingRect.left,
|
||||
pixelCoord.y - boundingRect.top,
|
||||
);
|
||||
|
||||
// The offset in pixels between the main workspace's origin and the upper
|
||||
// left corner of the injection div.
|
||||
const mainOffsetPixels = workspace.getOriginOffsetInPixels();
|
||||
|
||||
// The position of the new comment in pixels relative to the origin of the
|
||||
// main workspace.
|
||||
const finalOffset = Coordinate.difference(
|
||||
clientOffsetPixels,
|
||||
mainOffsetPixels,
|
||||
);
|
||||
// The position of the new comment in main workspace coordinates.
|
||||
finalOffset.scale(1 / workspace.scale);
|
||||
return finalOffset;
|
||||
}
|
||||
|
||||
/** Registers all block-scoped context menu items. */
|
||||
function registerBlockOptions_() {
|
||||
registerDuplicate();
|
||||
|
||||
@@ -181,7 +181,8 @@ let content = `
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
|
||||
.blocklyDragging.blocklyDraggingDelete {
|
||||
.blocklyDragging.blocklyDraggingDelete,
|
||||
.blocklyDragging.blocklyDraggingDelete .blocklyField {
|
||||
cursor: url("<<<PATH>>>/handdelete.cur"), auto;
|
||||
}
|
||||
|
||||
@@ -241,7 +242,7 @@ let content = `
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.blocklyIconGroup:not(:hover),
|
||||
.blocklyIconGroup:not(:hover):not(:focus),
|
||||
.blocklyIconGroupReadonly {
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
@@ -119,9 +119,6 @@ export abstract class Field<T = any>
|
||||
return this.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size of this field.
|
||||
*/
|
||||
protected set size_(newValue: Size) {
|
||||
this.size = newValue;
|
||||
}
|
||||
@@ -852,8 +849,7 @@ export abstract class Field<T = any>
|
||||
totalHeight = Math.max(totalHeight, constants!.FIELD_BORDER_RECT_HEIGHT);
|
||||
}
|
||||
|
||||
this.size_.height = totalHeight;
|
||||
this.size_.width = totalWidth;
|
||||
this.size_ = new Size(totalWidth, totalHeight);
|
||||
|
||||
this.positionTextElement_(xOffset, contentWidth);
|
||||
this.positionBorderRect_();
|
||||
@@ -1384,7 +1380,12 @@ export abstract class Field<T = any>
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {}
|
||||
onNodeFocus(): void {
|
||||
const block = this.getSourceBlock() as BlockSvg;
|
||||
block.workspace.scrollBoundsIntoView(
|
||||
block.getBoundingRectangleWithoutChildren(),
|
||||
);
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
onNodeBlur(): void {}
|
||||
|
||||
@@ -29,6 +29,7 @@ import * as aria from './utils/aria.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as parsing from './utils/parsing.js';
|
||||
import {Size} from './utils/size.js';
|
||||
import * as utilsString from './utils/string.js';
|
||||
import {Svg} from './utils/svg.js';
|
||||
|
||||
@@ -553,8 +554,7 @@ export class FieldDropdown extends Field<string> {
|
||||
} else {
|
||||
arrowWidth = dom.getTextWidth(this.arrow as SVGTSpanElement);
|
||||
}
|
||||
this.size_.width = imageWidth + arrowWidth + xPadding * 2;
|
||||
this.size_.height = height;
|
||||
this.size_ = new Size(imageWidth + arrowWidth + xPadding * 2, height);
|
||||
|
||||
let arrowX = 0;
|
||||
if (block.RTL) {
|
||||
@@ -595,8 +595,7 @@ export class FieldDropdown extends Field<string> {
|
||||
height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2,
|
||||
);
|
||||
}
|
||||
this.size_.width = textWidth + arrowWidth + xPadding * 2;
|
||||
this.size_.height = height;
|
||||
this.size_ = new Size(textWidth + arrowWidth + xPadding * 2, height);
|
||||
|
||||
this.positionTextElement_(xPadding, textWidth);
|
||||
}
|
||||
@@ -699,25 +698,30 @@ export class FieldDropdown extends Field<string> {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
} {
|
||||
let hasImages = false;
|
||||
let hasNonTextContent = false;
|
||||
const trimmedOptions = options.map((option): MenuOption => {
|
||||
if (option === FieldDropdown.SEPARATOR) return option;
|
||||
if (option === FieldDropdown.SEPARATOR) {
|
||||
hasNonTextContent = true;
|
||||
return option;
|
||||
}
|
||||
|
||||
const [label, value] = option;
|
||||
if (typeof label === 'string') {
|
||||
return [parsing.replaceMessageReferences(label), value];
|
||||
}
|
||||
|
||||
hasImages = true;
|
||||
hasNonTextContent = true;
|
||||
// Copy the image properties so they're not influenced by the original.
|
||||
// NOTE: No need to deep copy since image properties are only 1 level deep.
|
||||
const imageLabel = isImageProperties(label)
|
||||
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
|
||||
: {...label};
|
||||
: label;
|
||||
return [imageLabel, value];
|
||||
});
|
||||
|
||||
if (hasImages || options.length < 2) return {options: trimmedOptions};
|
||||
if (hasNonTextContent || options.length < 2) {
|
||||
return {options: trimmedOptions};
|
||||
}
|
||||
|
||||
const stringOptions = trimmedOptions as [string, string][];
|
||||
const stringLabels = stringOptions.map(([label]) => label);
|
||||
@@ -793,7 +797,7 @@ export class FieldDropdown extends Field<string> {
|
||||
} else if (typeof option[1] !== 'string') {
|
||||
foundError = true;
|
||||
console.error(
|
||||
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
|
||||
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
|
||||
Found ${option[1]} in: ${option}`,
|
||||
);
|
||||
} else if (
|
||||
@@ -806,7 +810,7 @@ export class FieldDropdown extends Field<string> {
|
||||
) {
|
||||
foundError = true;
|
||||
console.error(
|
||||
`Invalid option[${i}]: Each FieldDropdown option must have a string
|
||||
`Invalid option[${i}]: Each FieldDropdown option must have a string
|
||||
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
*/
|
||||
type InputTypes = string | number;
|
||||
|
||||
/**
|
||||
* The minimum width of an input field.
|
||||
*/
|
||||
const MINIMUM_WIDTH = 14;
|
||||
|
||||
/**
|
||||
* Abstract class for an editable input field.
|
||||
*
|
||||
@@ -102,11 +107,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
*/
|
||||
override SERIALIZABLE = true;
|
||||
|
||||
/**
|
||||
* Sets the size of this field. Although this appears to be a no-op, it must
|
||||
* exist since the getter is overridden below.
|
||||
*/
|
||||
protected override set size_(newValue: Size) {
|
||||
// Although this appears to be a no-op, it must exist since the getter is
|
||||
// overridden below.
|
||||
super.size_ = newValue;
|
||||
}
|
||||
|
||||
@@ -115,8 +118,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
*/
|
||||
protected override get size_() {
|
||||
const s = super.size_;
|
||||
if (s.width < 14) {
|
||||
s.width = 14;
|
||||
if (s.width < MINIMUM_WIDTH) {
|
||||
s.width = MINIMUM_WIDTH;
|
||||
}
|
||||
|
||||
return s;
|
||||
@@ -732,6 +735,23 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position a field's text element after a size change. This handles both LTR
|
||||
* and RTL positioning.
|
||||
*
|
||||
* @param xMargin x offset to use when positioning the text element.
|
||||
* @param contentWidth The content width.
|
||||
*/
|
||||
protected override positionTextElement_(
|
||||
xMargin: number,
|
||||
contentWidth: number,
|
||||
) {
|
||||
const effectiveWidth = xMargin * 2 + contentWidth;
|
||||
const delta =
|
||||
effectiveWidth < MINIMUM_WIDTH ? (MINIMUM_WIDTH - effectiveWidth) / 2 : 0;
|
||||
super.positionTextElement_(xMargin + delta, contentWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `getText_` developer hook to override the field's text
|
||||
* representation. When we're currently editing, return the current HTML value
|
||||
|
||||
@@ -56,11 +56,11 @@ export interface RegistrableField {
|
||||
* @param type The field type name as used in the JSON definition.
|
||||
* @param fieldClass The field class containing a fromJson function that can
|
||||
* construct an instance of the field.
|
||||
* @throws {Error} if the type name is empty, the field is already registered,
|
||||
* or the fieldClass is not an object containing a fromJson function.
|
||||
* @throws {Error} if the type name is empty or the fieldClass is not an object
|
||||
* containing a fromJson function.
|
||||
*/
|
||||
export function register(type: string, fieldClass: RegistrableField) {
|
||||
registry.register(registry.Type.FIELD, type, fieldClass);
|
||||
registry.register(registry.Type.FIELD, type, fieldClass, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -398,7 +398,11 @@ export class FlyoutButton
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {}
|
||||
onNodeFocus(): void {
|
||||
const xy = this.getPosition();
|
||||
const bounds = new Rect(xy.y, xy.y + this.height, xy.x, xy.x + this.width);
|
||||
this.workspace.scrollBoundsIntoView(bounds);
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
onNodeBlur(): void {}
|
||||
|
||||
@@ -309,6 +309,8 @@ export class FocusManager {
|
||||
* Note that this may update the specified node's element's tabindex to ensure
|
||||
* that it can be properly read out by screenreaders while focused.
|
||||
*
|
||||
* The focused node will not be automatically scrolled into view.
|
||||
*
|
||||
* @param focusableNode The node that should receive active focus.
|
||||
*/
|
||||
focusNode(focusableNode: IFocusableNode): void {
|
||||
@@ -423,6 +425,8 @@ export class FocusManager {
|
||||
* the returned lambda is called. Additionally, only 1 ephemeral focus context
|
||||
* can be active at any given time (attempting to activate more than one
|
||||
* simultaneously will result in an error being thrown).
|
||||
*
|
||||
* This method does not scroll the ephemerally focused element into view.
|
||||
*/
|
||||
takeEphemeralFocus(
|
||||
focusableElement: HTMLElement | SVGElement,
|
||||
@@ -439,7 +443,7 @@ export class FocusManager {
|
||||
if (this.focusedNode) {
|
||||
this.passivelyFocusNode(this.focusedNode, null);
|
||||
}
|
||||
focusableElement.focus();
|
||||
focusableElement.focus({preventScroll: true});
|
||||
|
||||
let hasFinishedEphemeralFocus = false;
|
||||
return () => {
|
||||
@@ -574,7 +578,7 @@ export class FocusManager {
|
||||
}
|
||||
|
||||
this.setNodeToVisualActiveFocus(node);
|
||||
elem.focus();
|
||||
elem.focus({preventScroll: true});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {BlockSvg} from '../block_svg.js';
|
||||
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
|
||||
import {EventType} from '../events/type.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import type {IBubble} from '../interfaces/i_bubble.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import type {ISerializable} from '../interfaces/i_serializable.js';
|
||||
import * as renderManagement from '../render_management.js';
|
||||
@@ -62,7 +61,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
|
||||
/**
|
||||
* The visibility of the bubble for this comment.
|
||||
*
|
||||
* This is used to track what the visibile state /should/ be, not necessarily
|
||||
* This is used to track what the visible state /should/ be, not necessarily
|
||||
* what it currently /is/. E.g. sometimes this will be true, but the block
|
||||
* hasn't been rendered yet, so the bubble will not currently be visible.
|
||||
*/
|
||||
@@ -340,7 +339,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
|
||||
}
|
||||
|
||||
/** See IHasBubble.getBubble. */
|
||||
getBubble(): IBubble | null {
|
||||
getBubble(): TextInputBubble | null {
|
||||
return this.textInputBubble;
|
||||
}
|
||||
|
||||
@@ -365,6 +364,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
|
||||
this.sourceBlock.workspace as WorkspaceSvg,
|
||||
this.getAnchorLocation(),
|
||||
this.getBubbleOwnerRect(),
|
||||
this,
|
||||
);
|
||||
this.textInputBubble.setText(this.getText());
|
||||
this.textInputBubble.setSize(this.bubbleSize, true);
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as tooltip from '../tooltip.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import * as idGenerator from '../utils/idgenerator.js';
|
||||
import {Rect} from '../utils/rect.js';
|
||||
import {Size} from '../utils/size.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
@@ -168,7 +169,16 @@ export abstract class Icon implements IIcon {
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {}
|
||||
onNodeFocus(): void {
|
||||
const blockBounds = (this.sourceBlock as BlockSvg).getBoundingRectangle();
|
||||
const bounds = new Rect(
|
||||
blockBounds.top + this.offsetInBlock.y,
|
||||
blockBounds.top + this.offsetInBlock.y + this.getSize().height,
|
||||
blockBounds.left + this.offsetInBlock.x,
|
||||
blockBounds.left + this.offsetInBlock.x + this.getSize().width,
|
||||
);
|
||||
(this.sourceBlock as BlockSvg).workspace.scrollBoundsIntoView(bounds);
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
onNodeBlur(): void {}
|
||||
|
||||
@@ -150,8 +150,17 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
|
||||
return markerConn;
|
||||
}
|
||||
|
||||
private createInsertionMarker(origBlock: BlockSvg) {
|
||||
const blockJson = blocks.save(origBlock, {
|
||||
/**
|
||||
* Transforms the given block into a JSON representation used to construct an
|
||||
* insertion marker.
|
||||
*
|
||||
* @param block The block to serialize and use as an insertion marker.
|
||||
* @returns A JSON-formatted string corresponding to a serialized
|
||||
* representation of the given block suitable for use as an insertion
|
||||
* marker.
|
||||
*/
|
||||
protected serializeBlockToInsertionMarker(block: BlockSvg) {
|
||||
const blockJson = blocks.save(block, {
|
||||
addCoordinates: false,
|
||||
addInputBlocks: false,
|
||||
addNextBlocks: false,
|
||||
@@ -160,10 +169,15 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
|
||||
|
||||
if (!blockJson) {
|
||||
throw new Error(
|
||||
`Failed to serialize source block. ${origBlock.toDevString()}`,
|
||||
`Failed to serialize source block. ${block.toDevString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return blockJson;
|
||||
}
|
||||
|
||||
private createInsertionMarker(origBlock: BlockSvg) {
|
||||
const blockJson = this.serializeBlockToInsertionMarker(origBlock);
|
||||
const result = blocks.append(blockJson, this.workspace) as BlockSvg;
|
||||
|
||||
// Turn shadow blocks that are created programmatically during
|
||||
|
||||
@@ -59,6 +59,9 @@ export interface IFocusableNode {
|
||||
* they should avoid the following:
|
||||
* - Creating or removing DOM elements (including via the renderer or drawer).
|
||||
* - Affecting focus via DOM focus() calls or the FocusManager.
|
||||
*
|
||||
* Implementations may consider scrolling themselves into view here; that is
|
||||
* not handled by the focus manager.
|
||||
*/
|
||||
onNodeFocus(): void;
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface IVariableMap<T extends IVariableModel<IVariableState>> {
|
||||
* Creates a new variable with the given name. If ID is not specified, the
|
||||
* variable map should create one. Returns the new variable.
|
||||
*/
|
||||
createVariable(name: string, id?: string, type?: string | null): T;
|
||||
createVariable(name: string, type?: string, id?: string | null): T;
|
||||
|
||||
/* Adds a variable to this variable map. */
|
||||
addVariable(variable: T): void;
|
||||
|
||||
76
core/keyboard_nav/block_comment_navigation_policy.ts
Normal file
76
core/keyboard_nav/block_comment_navigation_policy.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from an TextInputBubble.
|
||||
*/
|
||||
export class BlockCommentNavigationPolicy
|
||||
implements INavigationPolicy<TextInputBubble>
|
||||
{
|
||||
/**
|
||||
* Returns the first child of the given block comment.
|
||||
*
|
||||
* @param current The block comment to return the first child of.
|
||||
* @returns The text editor of the given block comment bubble.
|
||||
*/
|
||||
getFirstChild(current: TextInputBubble): IFocusableNode | null {
|
||||
return current.getEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given block comment.
|
||||
*
|
||||
* @param current The block comment to return the parent of.
|
||||
* @returns The parent block of the given block comment.
|
||||
*/
|
||||
getParent(current: TextInputBubble): IFocusableNode | null {
|
||||
return current.getOwner() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given block comment.
|
||||
*
|
||||
* @param _current The block comment to find the following element of.
|
||||
* @returns Null.
|
||||
*/
|
||||
getNextSibling(_current: TextInputBubble): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given block comment.
|
||||
*
|
||||
* @param _current The block comment to find the preceding element of.
|
||||
* @returns Null.
|
||||
*/
|
||||
getPreviousSibling(_current: TextInputBubble): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given block comment can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given block comment can be focused.
|
||||
*/
|
||||
isNavigable(current: TextInputBubble): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is an TextInputBubble.
|
||||
*/
|
||||
isApplicable(current: any): current is TextInputBubble {
|
||||
return current instanceof TextInputBubble;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ export class CommentBarButtonNavigationPolicy
|
||||
* @returns The parent comment of the given CommentBarButton.
|
||||
*/
|
||||
getParent(current: CommentBarButton): IFocusableNode | null {
|
||||
return current.getParentComment();
|
||||
return current
|
||||
.getCommentView()
|
||||
.workspace.getCommentById(current.getCommentView().commentId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +43,7 @@ export class CommentBarButtonNavigationPolicy
|
||||
* @returns The next CommentBarButton, if any.
|
||||
*/
|
||||
getNextSibling(current: CommentBarButton): IFocusableNode | null {
|
||||
const children = current.getParentComment().view.getCommentBarButtons();
|
||||
const children = current.getCommentView().getCommentBarButtons();
|
||||
const currentIndex = children.indexOf(current);
|
||||
if (currentIndex >= 0 && currentIndex + 1 < children.length) {
|
||||
return children[currentIndex + 1];
|
||||
@@ -56,7 +58,7 @@ export class CommentBarButtonNavigationPolicy
|
||||
* @returns The CommentBarButton's previous CommentBarButton, if any.
|
||||
*/
|
||||
getPreviousSibling(current: CommentBarButton): IFocusableNode | null {
|
||||
const children = current.getParentComment().view.getCommentBarButtons();
|
||||
const children = current.getCommentView().getCommentBarButtons();
|
||||
const currentIndex = children.indexOf(current);
|
||||
if (currentIndex > 0) {
|
||||
return children[currentIndex - 1];
|
||||
|
||||
54
core/keyboard_nav/comment_editor_navigation_policy.ts
Normal file
54
core/keyboard_nav/comment_editor_navigation_policy.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {CommentEditor} from '../comments/comment_editor.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a comment editor.
|
||||
* This is a no-op placeholder (other than isNavigable/isApplicable) since
|
||||
* comment editors handle their own navigation when editing ends.
|
||||
*/
|
||||
export class CommentEditorNavigationPolicy
|
||||
implements INavigationPolicy<CommentEditor>
|
||||
{
|
||||
getFirstChild(_current: CommentEditor): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
getParent(_current: CommentEditor): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
getNextSibling(_current: CommentEditor): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPreviousSibling(_current: CommentEditor): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given comment editor can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns False.
|
||||
*/
|
||||
isNavigable(current: CommentEditor): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is a CommentEditor.
|
||||
*/
|
||||
isApplicable(current: any): current is CommentEditor {
|
||||
return current instanceof CommentEditor;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {CommentIcon} from '../icons/comment_icon.js';
|
||||
import {Icon} from '../icons/icon.js';
|
||||
import {MutatorIcon} from '../icons/mutator_icon.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
@@ -29,6 +30,12 @@ export class IconNavigationPolicy implements INavigationPolicy<Icon> {
|
||||
getFocusManager().getFocusedNode() === current
|
||||
) {
|
||||
return current.getBubble()?.getWorkspace() ?? null;
|
||||
} else if (
|
||||
current instanceof CommentIcon &&
|
||||
current.bubbleIsVisible() &&
|
||||
getFocusManager().getFocusedNode() === current
|
||||
) {
|
||||
return current.getBubble()?.getEditor() ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -15,11 +15,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 * as registry from '../registry.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {Marker} from './marker.js';
|
||||
|
||||
/**
|
||||
@@ -390,20 +389,6 @@ export class LineCursor extends Marker {
|
||||
*/
|
||||
setCurNode(newNode: IFocusableNode) {
|
||||
getFocusManager().focusNode(newNode);
|
||||
|
||||
// Try to scroll cursor into view.
|
||||
if (newNode instanceof BlockSvg) {
|
||||
newNode.workspace.scrollBoundsIntoView(
|
||||
newNode.getBoundingRectangleWithoutChildren(),
|
||||
);
|
||||
} else if (newNode instanceof Field) {
|
||||
const block = newNode.getSourceBlock() as BlockSvg;
|
||||
block.workspace.scrollBoundsIntoView(
|
||||
block.getBoundingRectangleWithoutChildren(),
|
||||
);
|
||||
} else if (newNode instanceof RenderedWorkspaceComment) {
|
||||
newNode.workspace.scrollBoundsIntoView(newNode.getBoundingRectangle());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
|
||||
import {BlockCommentNavigationPolicy} from './keyboard_nav/block_comment_navigation_policy.js';
|
||||
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
|
||||
import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js';
|
||||
import {CommentEditorNavigationPolicy} from './keyboard_nav/comment_editor_navigation_policy.js';
|
||||
import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js';
|
||||
import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js';
|
||||
import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js';
|
||||
@@ -33,6 +35,8 @@ export class Navigator {
|
||||
new IconNavigationPolicy(),
|
||||
new WorkspaceCommentNavigationPolicy(),
|
||||
new CommentBarButtonNavigationPolicy(),
|
||||
new BlockCommentNavigationPolicy(),
|
||||
new CommentEditorNavigationPolicy(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,9 +119,9 @@ export class Type<_T> {
|
||||
/** @internal */
|
||||
static PASTER = new Type<IPaster<ICopyData, ICopyable<ICopyData>>>('paster');
|
||||
|
||||
static VARIABLE_MODEL = new Type<IVariableModelStatic<IVariableState>>(
|
||||
'variableModel',
|
||||
);
|
||||
static VARIABLE_MODEL = new Type<
|
||||
IVariableModelStatic<IVariableState> & IVariableModel<IVariableState>
|
||||
>('variableModel');
|
||||
|
||||
static VARIABLE_MAP = new Type<IVariableMap<IVariableModel<IVariableState>>>(
|
||||
'variableMap',
|
||||
|
||||
@@ -644,6 +644,9 @@ export class RenderedConnection
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {
|
||||
this.highlight();
|
||||
this.getSourceBlock().workspace.scrollBoundsIntoView(
|
||||
this.getSourceBlock().getBoundingRectangleWithoutChildren(),
|
||||
);
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
@@ -656,12 +659,12 @@ export class RenderedConnection
|
||||
return true;
|
||||
}
|
||||
|
||||
private findHighlightSvg(): SVGElement | null {
|
||||
private findHighlightSvg(): SVGPathElement | null {
|
||||
// This cast is valid as TypeScript's definition is wrong. See:
|
||||
// https://github.com/microsoft/TypeScript/issues/60996.
|
||||
return document.getElementById(this.id) as
|
||||
| unknown
|
||||
| null as SVGElement | null;
|
||||
| null as SVGPathElement | null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,9 +122,12 @@ export class Drawer {
|
||||
} else if (Types.isSpacer(elem)) {
|
||||
this.outlinePath_ += svgPaths.lineOnAxis('h', elem.width);
|
||||
}
|
||||
// No branch for a square corner, because it's a no-op.
|
||||
}
|
||||
// No branch for a square corner, because it's a no-op.
|
||||
this.outlinePath_ += svgPaths.lineOnAxis('v', topRow.height);
|
||||
this.outlinePath_ += svgPaths.lineOnAxis(
|
||||
'v',
|
||||
topRow.height - topRow.ascenderHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,10 +22,7 @@ import '../events/events_toolbox_item_select.js';
|
||||
import {EventType} from '../events/type.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {
|
||||
isAutoHideable,
|
||||
type IAutoHideable,
|
||||
} from '../interfaces/i_autohideable.js';
|
||||
import {type IAutoHideable} from '../interfaces/i_autohideable.js';
|
||||
import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js';
|
||||
import {isDeletable} from '../interfaces/i_deletable.js';
|
||||
import type {IDraggable} from '../interfaces/i_draggable.js';
|
||||
@@ -1150,10 +1147,7 @@ export class Toolbox
|
||||
// If navigating to anything other than the toolbox's flyout then clear the
|
||||
// selection so that the toolbox's flyout can automatically close.
|
||||
if (!nextTree || nextTree !== this.flyout?.getWorkspace()) {
|
||||
this.clearSelection();
|
||||
if (this.flyout && isAutoHideable(this.flyout)) {
|
||||
this.flyout.autoHide(false);
|
||||
}
|
||||
this.autoHide(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export const TOUCH_MAP: {[key: string]: string[]} = {
|
||||
'mouseup': ['pointerup', 'pointercancel'],
|
||||
'touchend': ['pointerup'],
|
||||
'touchcancel': ['pointercancel'],
|
||||
'pointerup': ['pointerup', 'pointercancel'],
|
||||
};
|
||||
|
||||
/** PID of queued long-press task. */
|
||||
|
||||
@@ -109,6 +109,9 @@ export class VariableMap
|
||||
variable: IVariableModel<IVariableState>,
|
||||
newType: string,
|
||||
): IVariableModel<IVariableState> {
|
||||
const oldType = variable.getType();
|
||||
if (oldType === newType) return variable;
|
||||
|
||||
this.variableMap.get(variable.getType())?.delete(variable.getId());
|
||||
variable.setType(newType);
|
||||
const newTypeVariables =
|
||||
@@ -118,6 +121,13 @@ export class VariableMap
|
||||
if (!this.variableMap.has(newType)) {
|
||||
this.variableMap.set(newType, newTypeVariables);
|
||||
}
|
||||
eventUtils.fire(
|
||||
new (eventUtils.get(EventType.VAR_TYPE_CHANGE))(
|
||||
variable,
|
||||
oldType,
|
||||
newType,
|
||||
),
|
||||
);
|
||||
return variable;
|
||||
}
|
||||
|
||||
@@ -245,9 +255,9 @@ export class VariableMap
|
||||
}
|
||||
const id = opt_id || idGenerator.genUid();
|
||||
const type = opt_type || '';
|
||||
const VariableModel = registry.getObject(
|
||||
const VariableModel = registry.getClassFromOptions(
|
||||
registry.Type.VARIABLE_MODEL,
|
||||
registry.DEFAULT,
|
||||
this.workspace.options,
|
||||
true,
|
||||
);
|
||||
if (!VariableModel) {
|
||||
|
||||
@@ -469,7 +469,7 @@ export class Workspace {
|
||||
'Blockly.Workspace.getVariableUsesById',
|
||||
'v12',
|
||||
'v13',
|
||||
'Blockly.Workspace.getVariableMap().getVariableUsesById',
|
||||
'Blockly.Variables.getVariableUsesById',
|
||||
);
|
||||
return getVariableUsesById(this, id);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {Block} from './block.js';
|
||||
import type {BlockSvg} from './block_svg.js';
|
||||
import type {BlocklyOptions} from './blockly_options.js';
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import {TextInputBubble} from './bubbles/textinput_bubble.js';
|
||||
import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js';
|
||||
import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js';
|
||||
import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js';
|
||||
@@ -1673,7 +1674,10 @@ export class WorkspaceSvg
|
||||
/** Clean up the workspace by ordering all the blocks in a column such that none overlap. */
|
||||
cleanUp() {
|
||||
this.setResizesEnabled(false);
|
||||
eventUtils.setGroup(true);
|
||||
const existingGroup = eventUtils.getGroup();
|
||||
if (!existingGroup) {
|
||||
eventUtils.setGroup(true);
|
||||
}
|
||||
|
||||
const topBlocks = this.getTopBlocks(true);
|
||||
const movableBlocks = topBlocks.filter((block) => block.isMovable());
|
||||
@@ -1721,7 +1725,7 @@ export class WorkspaceSvg
|
||||
block.getHeightWidth().height +
|
||||
minBlockHeight;
|
||||
}
|
||||
eventUtils.setGroup(false);
|
||||
eventUtils.setGroup(existingGroup);
|
||||
this.setResizesEnabled(true);
|
||||
}
|
||||
|
||||
@@ -2726,6 +2730,19 @@ export class WorkspaceSvg
|
||||
previousNode: IFocusableNode | null,
|
||||
): IFocusableNode | null {
|
||||
if (!previousNode) {
|
||||
const flyout = this.targetWorkspace?.getFlyout();
|
||||
if (this.isFlyout && flyout) {
|
||||
// Return the first focusable item of the flyout.
|
||||
return (
|
||||
flyout
|
||||
.getContents()
|
||||
.find((flyoutItem) => {
|
||||
const element = flyoutItem.getElement();
|
||||
return isFocusableNode(element) && element.canBeFocused();
|
||||
})
|
||||
?.getElement() ?? null
|
||||
);
|
||||
}
|
||||
return this.getTopBlocks(true)[0] ?? null;
|
||||
} else return null;
|
||||
}
|
||||
@@ -2868,6 +2885,11 @@ export class WorkspaceSvg
|
||||
bubble.getFocusableElement().id === id
|
||||
) {
|
||||
return bubble;
|
||||
} else if (
|
||||
bubble instanceof TextInputBubble &&
|
||||
bubble.getEditor().getFocusableElement().id === id
|
||||
) {
|
||||
return bubble.getEditor();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2889,11 +2911,9 @@ export class WorkspaceSvg
|
||||
// Only hide the flyout if the flyout's workspace is losing focus and that
|
||||
// focus isn't returning to the flyout itself, the toolbox, or ephemeral.
|
||||
if (getFocusManager().ephemeralFocusTaken()) return;
|
||||
const flyout = this.targetWorkspace.getFlyout();
|
||||
const toolbox = this.targetWorkspace.getToolbox();
|
||||
if (toolbox && nextTree === toolbox) return;
|
||||
if (toolbox) toolbox.clearSelection();
|
||||
if (flyout && isAutoHideable(flyout)) flyout.autoHide(false);
|
||||
if (isAutoHideable(toolbox)) toolbox.autoHide(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export function saveWorkspaceComment(
|
||||
if (!skipId) elem.setAttribute('id', comment.id);
|
||||
|
||||
const workspace = comment.workspace;
|
||||
const loc = comment.getRelativeToSurfaceXY();
|
||||
const loc = comment.getRelativeToSurfaceXY().clone();
|
||||
loc.x = workspace.RTL ? workspace.getWidth() - loc.x : loc.x;
|
||||
elem.setAttribute('x', `${loc.x}`);
|
||||
elem.setAttribute('y', `${loc.y}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import eslint from '@eslint/js';
|
||||
import googleStyle from 'eslint-config-google';
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
import mochaPlugin from 'eslint-plugin-mocha';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
@@ -88,7 +89,8 @@ function buildTSOverride({files, tsconfig}) {
|
||||
'@typescript-eslint/no-explicit-any': ['off'],
|
||||
// We use this pattern extensively for block (e.g. controls_if) interfaces.
|
||||
'@typescript-eslint/no-empty-object-type': ['off'],
|
||||
|
||||
// TSDoc doesn't support @yields, so don't require that we use it.
|
||||
'jsdoc/require-yields': ['off'],
|
||||
// params and returns docs are optional.
|
||||
'jsdoc/require-param-description': ['off'],
|
||||
'jsdoc/require-returns': ['off'],
|
||||
@@ -200,6 +202,9 @@ export default [
|
||||
},
|
||||
{
|
||||
files: ['tests/**'],
|
||||
plugins: {
|
||||
mocha: mochaPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
'Blockly': true,
|
||||
@@ -219,6 +224,7 @@ export default [
|
||||
'jsdoc/check-tag-names': ['warn', {'definedTags': ['record']}],
|
||||
'jsdoc/tag-lines': ['off'],
|
||||
'jsdoc/no-defaults': ['off'],
|
||||
'mocha/no-exclusive-tests': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,7 +45,11 @@ import {
|
||||
publishBeta,
|
||||
recompile,
|
||||
} from './scripts/gulpfiles/release_tasks.mjs';
|
||||
import {generators, test} from './scripts/gulpfiles/test_tasks.mjs';
|
||||
import {
|
||||
generators,
|
||||
interactiveMocha,
|
||||
test,
|
||||
} from './scripts/gulpfiles/test_tasks.mjs';
|
||||
|
||||
const clean = parallel(cleanBuildDir, cleanReleaseDir);
|
||||
|
||||
@@ -80,6 +84,7 @@ export {
|
||||
clean,
|
||||
test,
|
||||
generators as testGenerators,
|
||||
interactiveMocha,
|
||||
buildAdvancedCompilationTest,
|
||||
createRC as gitCreateRC,
|
||||
docs,
|
||||
|
||||
714
package-lock.json
generated
714
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "blockly",
|
||||
"version": "12.2.0",
|
||||
"version": "12.3.0",
|
||||
"description": "Blockly is a library for building visual programming editors.",
|
||||
"keywords": [
|
||||
"blockly"
|
||||
@@ -45,7 +45,7 @@
|
||||
"test": "gulp test",
|
||||
"test:browser": "cd tests/browser && npx mocha",
|
||||
"test:generators": "gulp testGenerators",
|
||||
"test:mocha:interactive": "npm run build && concurrently -n tsc,server \"tsc --watch --preserveWatchOutput --outDir \"build/src\" --declarationDir \"build/declarations\"\" \"http-server ./ -o /tests/mocha/index.html -c-1\"",
|
||||
"test:mocha:interactive": "npm run build && concurrently -n tsc,server \"tsc --watch --preserveWatchOutput --outDir \"build/src\" --declarationDir \"build/declarations\"\" \"gulp interactiveMocha\"",
|
||||
"test:compile:advanced": "gulp buildAdvancedCompilationTest --debug",
|
||||
"updateGithubPages": "npm ci && gulp gitUpdateGithubPages"
|
||||
},
|
||||
@@ -100,24 +100,26 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@blockly/block-test": "^7.0.1",
|
||||
"@blockly/dev-tools": "^9.0.0",
|
||||
"@blockly/theme-modern": "^6.0.3",
|
||||
"@blockly/block-test": "^7.0.2",
|
||||
"@blockly/dev-tools": "^9.0.2",
|
||||
"@blockly/theme-modern": "^7.0.1",
|
||||
"@hyperjump/browser": "^1.1.4",
|
||||
"@hyperjump/json-schema": "^1.5.0",
|
||||
"@microsoft/api-documenter": "^7.22.4",
|
||||
"@microsoft/api-documenter": "7.22.4",
|
||||
"@microsoft/api-extractor": "^7.29.5",
|
||||
"ajv": "^8.17.1",
|
||||
"async-done": "^2.0.0",
|
||||
"chai": "^5.1.1",
|
||||
"chai": "^6.0.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-plugin-jsdoc": "^51.3.1",
|
||||
"eslint-plugin-jsdoc": "^52.0.2",
|
||||
"eslint-plugin-mocha": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"glob": "^11.0.1",
|
||||
"globals": "^16.0.0",
|
||||
"google-closure-compiler": "^20250625.0.0",
|
||||
"google-closure-compiler": "^20250709.0.0",
|
||||
"gulp": "^5.0.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-gzip": "^1.4.2",
|
||||
@@ -136,6 +138,7 @@
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"puppeteer-core": "^24.17.0",
|
||||
"readline-sync": "^1.4.10",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/node_modules/@microsoft/api-documenter/lib/documenters/MarkdownDocumenter.js b/node_modules/@microsoft/api-documenter/lib/documenters/MarkdownDocumenter.js
|
||||
index 0f4e2ba..3af2014 100644
|
||||
index 5284d10..4f8b439 100644
|
||||
--- a/node_modules/@microsoft/api-documenter/lib/documenters/MarkdownDocumenter.js
|
||||
+++ b/node_modules/@microsoft/api-documenter/lib/documenters/MarkdownDocumenter.js
|
||||
@@ -893,12 +893,15 @@ class MarkdownDocumenter {
|
||||
@@ -877,12 +877,14 @@ class MarkdownDocumenter {
|
||||
}
|
||||
_writeBreadcrumb(output, apiItem) {
|
||||
const configuration = this._tsdocConfiguration;
|
||||
@@ -19,28 +19,23 @@ index 0f4e2ba..3af2014 100644
|
||||
+ // linkText: 'Home',
|
||||
+ // urlDestination: this._getLinkFilenameForApiItem(this._apiModel)
|
||||
+ // }));
|
||||
+
|
||||
+ let first = true;
|
||||
for (const hierarchyItem of apiItem.getHierarchy()) {
|
||||
switch (hierarchyItem.kind) {
|
||||
case api_extractor_model_1.ApiItemKind.Model:
|
||||
@@ -908,18 +911,24 @@ class MarkdownDocumenter {
|
||||
@@ -892,18 +894,23 @@ class MarkdownDocumenter {
|
||||
// this may change in the future.
|
||||
break;
|
||||
default:
|
||||
- output.appendNodesInParagraph([
|
||||
- new tsdoc_1.DocPlainText({
|
||||
- configuration,
|
||||
- text: ' > '
|
||||
- }),
|
||||
+ if (!first) {
|
||||
+ // Only print the breadcrumb separator if it's not the first item we're printing.
|
||||
+ output.appendNodeInParagraph(
|
||||
+ new tsdoc_1.DocPlainText({
|
||||
+ configuration,
|
||||
+ text: ' > '
|
||||
+ })
|
||||
+ );
|
||||
new tsdoc_1.DocPlainText({
|
||||
configuration,
|
||||
text: ' > '
|
||||
- }),
|
||||
+ }));
|
||||
+ }
|
||||
+ first = false;
|
||||
+ output.appendNodeInParagraph(
|
||||
@@ -55,7 +50,7 @@ index 0f4e2ba..3af2014 100644
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -992,11 +1001,8 @@ class MarkdownDocumenter {
|
||||
@@ -968,11 +975,8 @@ class MarkdownDocumenter {
|
||||
// For overloaded methods, add a suffix such as "MyClass.myMethod_2".
|
||||
let qualifiedName = Utilities_1.Utilities.getSafeFilenameForName(hierarchyItem.displayName);
|
||||
if (api_extractor_model_1.ApiParameterListMixin.isBaseClassOf(hierarchyItem)) {
|
||||
@@ -69,7 +64,7 @@ index 0f4e2ba..3af2014 100644
|
||||
}
|
||||
switch (hierarchyItem.kind) {
|
||||
case api_extractor_model_1.ApiItemKind.Model:
|
||||
@@ -1007,7 +1013,8 @@ class MarkdownDocumenter {
|
||||
@@ -983,7 +987,8 @@ class MarkdownDocumenter {
|
||||
baseName = Utilities_1.Utilities.getSafeFilenameForName(node_core_library_1.PackageName.getUnscopedName(hierarchyItem.displayName));
|
||||
break;
|
||||
default:
|
||||
@@ -2,8 +2,8 @@ import {execSync} from 'child_process';
|
||||
import {Extractor} from 'markdown-tables-to-json';
|
||||
import * as fs from 'fs';
|
||||
import * as gulp from 'gulp';
|
||||
import * as header from 'gulp-header';
|
||||
import * as replace from 'gulp-replace';
|
||||
import header from 'gulp-header';
|
||||
import replace from 'gulp-replace';
|
||||
|
||||
const DOCS_DIR = 'docs';
|
||||
|
||||
|
||||
@@ -257,9 +257,9 @@ async function metadata() {
|
||||
* Run Mocha tests inside a browser.
|
||||
* @return {Promise} Asynchronous result.
|
||||
*/
|
||||
async function mocha() {
|
||||
async function mocha(exitOnCompletion = true) {
|
||||
return runTestTask('mocha', async () => {
|
||||
const result = await runMochaTestsInBrowser().catch(e => {
|
||||
const result = await runMochaTestsInBrowser(exitOnCompletion).catch(e => {
|
||||
throw e;
|
||||
});
|
||||
if (result) {
|
||||
@@ -269,6 +269,14 @@ async function mocha() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Mocha tests inside a browser and keep the browser open upon completion.
|
||||
* @return {Promise} Asynchronous result.
|
||||
*/
|
||||
export async function interactiveMocha() {
|
||||
return mocha(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for comparison file.
|
||||
* @param {string} file1 First target file.
|
||||
|
||||
@@ -101,6 +101,45 @@ suite('Right Clicking on Blocks', function () {
|
||||
await contextMenuSelect(this.browser, this.block, 'Remove Comment');
|
||||
chai.assert.isNull(await getCommentText(this.browser, this.block.id));
|
||||
});
|
||||
|
||||
test('does not scroll the page when node is ephemerally focused', async function () {
|
||||
const initialScroll = await this.browser.execute(() => {
|
||||
return window.scrollY;
|
||||
});
|
||||
// This left-right-left sequence was necessary to reproduce unintended
|
||||
// scrolling; regardless of the number of clicks/context menu activations,
|
||||
// the page should not scroll.
|
||||
this.block.click({button: 2});
|
||||
this.block.click({button: 0});
|
||||
this.block.click({button: 2});
|
||||
await this.browser.pause(250);
|
||||
const finalScroll = await this.browser.execute(() => {
|
||||
return window.scrollY;
|
||||
});
|
||||
|
||||
chai.assert.equal(initialScroll, finalScroll);
|
||||
});
|
||||
|
||||
test('does not scroll the page when node is actively focused', async function () {
|
||||
await this.browser.setWindowSize(500, 300);
|
||||
await this.browser.setViewport({width: 500, height: 300});
|
||||
const initialScroll = await this.browser.execute((blockId) => {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
return window.scrollY;
|
||||
}, this.block.id);
|
||||
await this.browser.execute(() => {
|
||||
Blockly.getFocusManager().focusNode(
|
||||
Blockly.getMainWorkspace().getToolbox(),
|
||||
);
|
||||
});
|
||||
const finalScroll = await this.browser.execute(() => {
|
||||
return window.scrollY;
|
||||
});
|
||||
|
||||
chai.assert.equal(initialScroll, finalScroll);
|
||||
await this.browser.setWindowSize(800, 600);
|
||||
await this.browser.setViewport({width: 800, height: 600});
|
||||
});
|
||||
});
|
||||
|
||||
suite('Disabling', function () {
|
||||
@@ -199,3 +238,224 @@ suite('Disabling', function () {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
suite('Focused nodes are scrolled into bounds', function () {
|
||||
// Setting timeout to unlimited as the webdriver takes a longer time to run
|
||||
// than most mocha tests.
|
||||
this.timeout(0);
|
||||
|
||||
// Setup Selenium for all of the tests
|
||||
suiteSetup(async function () {
|
||||
this.browser = await testSetup(testFileLocations.PLAYGROUND);
|
||||
await this.browser.execute(() => {
|
||||
window.focusScrollTest = async (testcase) => {
|
||||
const workspace = Blockly.getMainWorkspace();
|
||||
const metrics = workspace.getMetricsManager();
|
||||
const initialViewport = metrics.getViewMetrics(true);
|
||||
const elementBounds = await testcase(workspace);
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
const scrolledViewport = metrics.getViewMetrics(true);
|
||||
const workspaceBounds = new Blockly.utils.Rect(
|
||||
scrolledViewport.top,
|
||||
scrolledViewport.top + scrolledViewport.height,
|
||||
scrolledViewport.left,
|
||||
scrolledViewport.left + scrolledViewport.width,
|
||||
);
|
||||
return {
|
||||
changed:
|
||||
JSON.stringify(initialViewport) !==
|
||||
JSON.stringify(scrolledViewport),
|
||||
intersects: elementBounds.intersects(workspaceBounds),
|
||||
contains: workspaceBounds.contains(
|
||||
elementBounds.getOrigin().x,
|
||||
elementBounds.getOrigin().y,
|
||||
),
|
||||
elementBounds,
|
||||
workspaceBounds,
|
||||
};
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
setup(async function () {
|
||||
await this.browser.execute(() => {
|
||||
Blockly.serialization.blocks.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'x': -500,
|
||||
'y': -500,
|
||||
},
|
||||
Blockly.getMainWorkspace(),
|
||||
);
|
||||
Blockly.serialization.blocks.append(
|
||||
{
|
||||
'type': 'controls_if',
|
||||
'x': 500,
|
||||
'y': 500,
|
||||
},
|
||||
Blockly.getMainWorkspace(),
|
||||
);
|
||||
Blockly.getMainWorkspace().zoomCenter(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('Focused blocks scroll into bounds', async function () {
|
||||
const result = await this.browser.execute(async () => {
|
||||
return await window.focusScrollTest(async (workspace) => {
|
||||
const block = workspace.getTopBlocks()[0];
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
return block.getBoundingRectangleWithoutChildren();
|
||||
});
|
||||
});
|
||||
chai.assert.isTrue(result.intersects);
|
||||
chai.assert.isTrue(result.contains);
|
||||
chai.assert.isTrue(result.changed);
|
||||
});
|
||||
|
||||
test('Focused bubbles scroll into bounds', async function () {
|
||||
const result = await this.browser.execute(async () => {
|
||||
return await window.focusScrollTest(async (workspace) => {
|
||||
const block = workspace.getTopBlocks()[0];
|
||||
block.setCommentText('hello world');
|
||||
const icon = block.getIcon(Blockly.icons.IconType.COMMENT);
|
||||
icon.setBubbleVisible(true);
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
icon.setBubbleLocation(new Blockly.utils.Coordinate(-510, -510));
|
||||
Blockly.getFocusManager().focusNode(icon.getBubble());
|
||||
const xy = icon.getBubble().getRelativeToSurfaceXY();
|
||||
const size = icon.getBubble().getSize();
|
||||
return new Blockly.utils.Rect(
|
||||
xy.y,
|
||||
xy.y + size.height,
|
||||
xy.x,
|
||||
xy.x + size.width,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
chai.assert.isTrue(result.intersects);
|
||||
chai.assert.isTrue(result.contains);
|
||||
chai.assert.isTrue(result.changed);
|
||||
});
|
||||
|
||||
test('Comment bar buttons scroll into bounds', async function () {
|
||||
const result = await this.browser.execute(async () => {
|
||||
return await window.focusScrollTest(async (workspace) => {
|
||||
const comment = new Blockly.comments.RenderedWorkspaceComment(
|
||||
workspace,
|
||||
);
|
||||
comment.moveTo(new Blockly.utils.Coordinate(-300, 500));
|
||||
const commentBarButton = comment.view.getCommentBarButtons()[0];
|
||||
Blockly.getFocusManager().focusNode(commentBarButton);
|
||||
const xy = comment.view.getRelativeToSurfaceXY();
|
||||
const size = comment.view.getSize();
|
||||
// Comment bar buttons scroll their parent comment view into view.
|
||||
return new Blockly.utils.Rect(
|
||||
xy.y,
|
||||
xy.y + size.height,
|
||||
xy.x,
|
||||
xy.x + size.width,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
chai.assert.isTrue(result.intersects);
|
||||
chai.assert.isTrue(result.contains);
|
||||
chai.assert.isTrue(result.changed);
|
||||
});
|
||||
|
||||
test('Comment editors scroll into bounds', async function () {
|
||||
const result = await this.browser.execute(async () => {
|
||||
return await window.focusScrollTest(async (workspace) => {
|
||||
const comment = new Blockly.comments.RenderedWorkspaceComment(
|
||||
workspace,
|
||||
);
|
||||
comment.moveTo(new Blockly.utils.Coordinate(-300, 500));
|
||||
const commentEditor = comment.view.getEditorFocusableNode();
|
||||
Blockly.getFocusManager().focusNode(commentEditor);
|
||||
// Comment editor bounds can't be calculated externally since they
|
||||
// depend on private properties, but the comment view is a reasonable
|
||||
// proxy.
|
||||
const xy = comment.view.getRelativeToSurfaceXY();
|
||||
const size = comment.view.getSize();
|
||||
return new Blockly.utils.Rect(
|
||||
xy.y,
|
||||
xy.y + size.height,
|
||||
xy.x,
|
||||
xy.x + size.width,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
chai.assert.isTrue(result.intersects);
|
||||
chai.assert.isTrue(result.contains);
|
||||
chai.assert.isTrue(result.changed);
|
||||
});
|
||||
|
||||
test('Workspace comments scroll into bounds', async function () {
|
||||
const result = await this.browser.execute(async () => {
|
||||
return await window.focusScrollTest(async (workspace) => {
|
||||
const comment = new Blockly.comments.RenderedWorkspaceComment(
|
||||
workspace,
|
||||
);
|
||||
comment.moveTo(new Blockly.utils.Coordinate(-500, 500));
|
||||
Blockly.getFocusManager().focusNode(comment);
|
||||
return comment.getBoundingRectangle();
|
||||
});
|
||||
});
|
||||
|
||||
chai.assert.isTrue(result.intersects);
|
||||
chai.assert.isTrue(result.contains);
|
||||
chai.assert.isTrue(result.changed);
|
||||
});
|
||||
|
||||
test('Icons scroll into bounds', async function () {
|
||||
const result = await this.browser.execute(async () => {
|
||||
return await window.focusScrollTest(async (workspace) => {
|
||||
const block = workspace.getTopBlocks()[0];
|
||||
block.setWarningText('this is bad');
|
||||
const icon = block.getIcon(Blockly.icons.IconType.WARNING);
|
||||
Blockly.getFocusManager().focusNode(icon);
|
||||
// Icon bounds can't be calculated externally since they depend on
|
||||
// protected properties, but the parent block is a reasonable proxy.
|
||||
return block.getBoundingRectangleWithoutChildren();
|
||||
});
|
||||
});
|
||||
|
||||
chai.assert.isTrue(result.intersects);
|
||||
chai.assert.isTrue(result.contains);
|
||||
chai.assert.isTrue(result.changed);
|
||||
});
|
||||
|
||||
test('Fields scroll into bounds', async function () {
|
||||
const result = await this.browser.execute(async () => {
|
||||
return await window.focusScrollTest(async (workspace) => {
|
||||
const block = workspace.getTopBlocks()[0];
|
||||
const field = block.getField('TEXT');
|
||||
Blockly.getFocusManager().focusNode(field);
|
||||
// Fields scroll their source block into view.
|
||||
return block.getBoundingRectangleWithoutChildren();
|
||||
});
|
||||
});
|
||||
|
||||
chai.assert.isTrue(result.intersects);
|
||||
chai.assert.isTrue(result.contains);
|
||||
chai.assert.isTrue(result.changed);
|
||||
});
|
||||
|
||||
test('Connections scroll into bounds', async function () {
|
||||
const result = await this.browser.execute(async () => {
|
||||
return await window.focusScrollTest(async (workspace) => {
|
||||
const block = workspace.getBlocksByType('controls_if')[0];
|
||||
Blockly.getFocusManager().focusNode(block.nextConnection);
|
||||
// Connection bounds can't be calculated externally since they depend on
|
||||
// protected properties, but the parent block is a reasonable proxy.
|
||||
return block.getBoundingRectangleWithoutChildren();
|
||||
});
|
||||
});
|
||||
|
||||
chai.assert.isTrue(result.intersects);
|
||||
chai.assert.isTrue(result.contains);
|
||||
chai.assert.isTrue(result.changed);
|
||||
});
|
||||
});
|
||||
|
||||
611
tests/browser/test/clipboard_test.mjs
Normal file
611
tests/browser/test/clipboard_test.mjs
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as chai from 'chai';
|
||||
import {Key} from 'webdriverio';
|
||||
import {
|
||||
PAUSE_TIME,
|
||||
clickWorkspace,
|
||||
focusOnBlock,
|
||||
getAllBlocks,
|
||||
getBlockTypeFromWorkspace,
|
||||
getCategory,
|
||||
getSelectedBlockId,
|
||||
getSelectedBlockType,
|
||||
openMutatorForBlock,
|
||||
testFileLocations,
|
||||
testSetup,
|
||||
} from './test_setup.mjs';
|
||||
|
||||
const testBlockJson = {
|
||||
'blocks': {
|
||||
'languageVersion': 0,
|
||||
'blocks': [
|
||||
{
|
||||
'type': 'controls_repeat_ext',
|
||||
'id': 'controls_repeat_1',
|
||||
'x': 88,
|
||||
'y': 88,
|
||||
'inputs': {
|
||||
'TIMES': {
|
||||
'shadow': {
|
||||
'type': 'math_number',
|
||||
'id': 'math_number_shadow_1',
|
||||
'fields': {
|
||||
'NUM': 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
'DO': {
|
||||
'block': {
|
||||
'type': 'controls_if',
|
||||
'id': 'controls_if_1',
|
||||
'inputs': {
|
||||
'IF0': {
|
||||
'block': {
|
||||
'type': 'logic_boolean',
|
||||
'id': 'logic_boolean_1',
|
||||
'fields': {
|
||||
'BOOL': 'TRUE',
|
||||
},
|
||||
},
|
||||
},
|
||||
'DO0': {
|
||||
'block': {
|
||||
'type': 'text_print',
|
||||
'id': 'text_print_1',
|
||||
'inputs': {
|
||||
'TEXT': {
|
||||
'shadow': {
|
||||
'type': 'text',
|
||||
'id': 'text_shadow_1',
|
||||
'fields': {
|
||||
'TEXT': 'abc',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
async function loadStartBlocks(browser) {
|
||||
await browser.execute((stringifiedJson) => {
|
||||
// Hangs forever if the json isn't stringified ¯\_(ツ)_/¯
|
||||
const testBlockJson = JSON.parse(stringifiedJson);
|
||||
const workspace = Blockly.common.getMainWorkspace();
|
||||
Blockly.serialization.workspaces.load(testBlockJson, workspace);
|
||||
}, JSON.stringify(testBlockJson));
|
||||
await browser.pause(PAUSE_TIME);
|
||||
}
|
||||
|
||||
suite('Clipboard test', async function () {
|
||||
// Setting timeout to unlimited as these tests take longer time to run
|
||||
this.timeout(0);
|
||||
|
||||
// Clear the workspace and load start blocks
|
||||
setup(async function () {
|
||||
this.browser = await testSetup(testFileLocations.PLAYGROUND);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
});
|
||||
|
||||
test('Paste block to/from main workspace', async function () {
|
||||
await loadStartBlocks(this.browser);
|
||||
// Select and copy the "true" block
|
||||
await focusOnBlock(this.browser, 'logic_boolean_1');
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check how many blocks there are before pasting
|
||||
const allBlocksBeforePaste = await getAllBlocks(this.browser);
|
||||
|
||||
// Paste the block while still in the main workspace
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check result
|
||||
const allBlocksAfterPaste = await getAllBlocks(this.browser);
|
||||
chai.assert.equal(
|
||||
allBlocksAfterPaste.length,
|
||||
allBlocksBeforePaste.length + 1,
|
||||
'Expected there to be one additional block after paste',
|
||||
);
|
||||
const focusedBlockId = await getSelectedBlockId(this.browser);
|
||||
chai.assert.notEqual(
|
||||
focusedBlockId,
|
||||
'logic_boolean_1',
|
||||
'Newly pasted block should be selected',
|
||||
);
|
||||
const focusedBlockType = await getSelectedBlockType(this.browser);
|
||||
chai.assert.equal(
|
||||
focusedBlockType,
|
||||
'logic_boolean',
|
||||
'Newly pasted block should be selected',
|
||||
);
|
||||
});
|
||||
|
||||
test('Copying a block also copies and pastes its children', async function () {
|
||||
await loadStartBlocks(this.browser);
|
||||
// Select and copy the "if/else" block which has children
|
||||
await focusOnBlock(this.browser, 'controls_if_1');
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check how many blocks there are before pasting
|
||||
const allBlocksBeforePaste = await getAllBlocks(this.browser);
|
||||
|
||||
// Paste the block while still in the main workspace
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check result
|
||||
const allBlocksAfterPaste = await getAllBlocks(this.browser);
|
||||
chai.assert.equal(
|
||||
allBlocksAfterPaste.length,
|
||||
allBlocksBeforePaste.length + 4,
|
||||
'Expected there to be four additional blocks after paste',
|
||||
);
|
||||
});
|
||||
|
||||
test('Paste shadow block to/from main workspace', async function () {
|
||||
await loadStartBlocks(this.browser);
|
||||
// Select and copy the shadow number block
|
||||
await focusOnBlock(this.browser, 'math_number_shadow_1');
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check how many blocks there are before pasting
|
||||
const allBlocksBeforePaste = await getAllBlocks(this.browser);
|
||||
|
||||
// Paste the block while still in the main workspace
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check result
|
||||
const allBlocksAfterPaste = await getAllBlocks(this.browser);
|
||||
chai.assert.equal(
|
||||
allBlocksAfterPaste.length,
|
||||
allBlocksBeforePaste.length + 1,
|
||||
'Expected there to be one additional block after paste',
|
||||
);
|
||||
const focusedBlockId = await getSelectedBlockId(this.browser);
|
||||
chai.assert.notEqual(
|
||||
focusedBlockId,
|
||||
'math_number_shadow_1',
|
||||
'Newly pasted block should be selected',
|
||||
);
|
||||
const focusedBlockType = await getSelectedBlockType(this.browser);
|
||||
chai.assert.equal(
|
||||
focusedBlockType,
|
||||
'math_number',
|
||||
'Newly pasted block should be selected',
|
||||
);
|
||||
const focusedBlockIsShadow = await this.browser.execute(() => {
|
||||
return Blockly.common.getSelected().isShadow();
|
||||
});
|
||||
chai.assert.isFalse(
|
||||
focusedBlockIsShadow,
|
||||
'Expected the pasted version of the block to not be a shadow block',
|
||||
);
|
||||
});
|
||||
|
||||
test('Copy block from flyout, paste to main workspace', async function () {
|
||||
// Open flyout
|
||||
await getCategory(this.browser, 'Logic').then((category) =>
|
||||
category.click(),
|
||||
);
|
||||
|
||||
// Focus on first block in flyout
|
||||
await this.browser.execute(() => {
|
||||
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
|
||||
const block = ws.getBlocksByType('controls_if')[0];
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
});
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Copy
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Select the main workspace
|
||||
await clickWorkspace(this.browser);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Paste
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check that the block is now on the workspace and selected
|
||||
const allBlocks = await getAllBlocks(this.browser);
|
||||
chai.assert.equal(
|
||||
allBlocks.length,
|
||||
1,
|
||||
'Expected there to be one block on main workspace after paste from flyout',
|
||||
);
|
||||
|
||||
const focusedBlockType = await getSelectedBlockType(this.browser);
|
||||
chai.assert.equal(
|
||||
focusedBlockType,
|
||||
'controls_if',
|
||||
'Newly pasted block should be selected',
|
||||
);
|
||||
});
|
||||
|
||||
test('Copy block from flyout, paste while flyout focused', async function () {
|
||||
// Open flyout
|
||||
await getCategory(this.browser, 'Logic').then((category) =>
|
||||
category.click(),
|
||||
);
|
||||
|
||||
// Focus on first block in flyout
|
||||
await this.browser.execute(() => {
|
||||
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
|
||||
const block = ws.getBlocksByType('controls_if')[0];
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
});
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Copy
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Paste
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check that the flyout is closed
|
||||
const flyoutIsVisible = await this.browser
|
||||
.$('.blocklyToolboxFlyout')
|
||||
.then((elem) => elem.isDisplayed());
|
||||
chai.assert.isFalse(flyoutIsVisible, 'Expected flyout to not be open');
|
||||
|
||||
// Check that the block is now on the main workspace and selected
|
||||
const allBlocks = await getAllBlocks(this.browser);
|
||||
chai.assert.equal(
|
||||
allBlocks.length,
|
||||
1,
|
||||
'Expected there to be one block on main workspace after paste from flyout',
|
||||
);
|
||||
|
||||
const focusedBlockType = await getSelectedBlockType(this.browser);
|
||||
chai.assert.equal(
|
||||
focusedBlockType,
|
||||
'controls_if',
|
||||
'Newly pasted block should be selected',
|
||||
);
|
||||
});
|
||||
|
||||
test('Copy block from mutator flyout, paste to mutator workspace', async function () {
|
||||
// Load the start blocks
|
||||
await loadStartBlocks(this.browser);
|
||||
|
||||
// Open the controls_if mutator
|
||||
const block = await getBlockTypeFromWorkspace(
|
||||
this.browser,
|
||||
'controls_if',
|
||||
0,
|
||||
);
|
||||
await openMutatorForBlock(this.browser, block);
|
||||
|
||||
// Select the first block in the mutator flyout
|
||||
await this.browser.execute(
|
||||
(blockId, mutatorBlockType) => {
|
||||
const flyoutBlock = Blockly.getMainWorkspace()
|
||||
.getBlockById(blockId)
|
||||
.mutator.getWorkspace()
|
||||
.getFlyout()
|
||||
.getWorkspace()
|
||||
.getBlocksByType(mutatorBlockType)[0];
|
||||
|
||||
Blockly.getFocusManager().focusNode(flyoutBlock);
|
||||
},
|
||||
'controls_if_1',
|
||||
'controls_if_elseif',
|
||||
);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Copy
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Paste
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check that the block is now in the mutator workspace and selected
|
||||
const numberOfIfElseBlocks = await this.browser.execute(
|
||||
(blockId, mutatorBlockType) => {
|
||||
return Blockly.getMainWorkspace()
|
||||
.getBlockById(blockId)
|
||||
.mutator.getWorkspace()
|
||||
.getBlocksByType(mutatorBlockType).length;
|
||||
},
|
||||
'controls_if_1',
|
||||
'controls_if_elseif',
|
||||
);
|
||||
|
||||
chai.assert.equal(
|
||||
numberOfIfElseBlocks,
|
||||
1,
|
||||
'Expected there to be one if_else block in mutator workspace',
|
||||
);
|
||||
|
||||
const focusedBlockType = await getSelectedBlockType(this.browser);
|
||||
chai.assert.equal(
|
||||
focusedBlockType,
|
||||
'controls_if_elseif',
|
||||
'Newly pasted block should be selected',
|
||||
);
|
||||
});
|
||||
|
||||
test('Copy block from mutator flyout, paste to main workspace while mutator open', async function () {
|
||||
// Load the start blocks
|
||||
await loadStartBlocks(this.browser);
|
||||
|
||||
// Open the controls_if mutator
|
||||
const block = await getBlockTypeFromWorkspace(
|
||||
this.browser,
|
||||
'controls_if',
|
||||
0,
|
||||
);
|
||||
await openMutatorForBlock(this.browser, block);
|
||||
|
||||
// Select the first block in the mutator flyout
|
||||
await this.browser.execute(
|
||||
(blockId, mutatorBlockType) => {
|
||||
const flyoutBlock = Blockly.getMainWorkspace()
|
||||
.getBlockById(blockId)
|
||||
.mutator.getWorkspace()
|
||||
.getFlyout()
|
||||
.getWorkspace()
|
||||
.getBlocksByType(mutatorBlockType)[0];
|
||||
|
||||
Blockly.getFocusManager().focusNode(flyoutBlock);
|
||||
},
|
||||
'controls_if_1',
|
||||
'controls_if_elseif',
|
||||
);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Copy
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Click the main workspace
|
||||
await clickWorkspace(this.browser);
|
||||
|
||||
// Paste
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check that the block is now in the mutator workspace and selected
|
||||
const numberOfIfElseBlocks = await this.browser.execute(
|
||||
(blockId, mutatorBlockType) => {
|
||||
return Blockly.getMainWorkspace()
|
||||
.getBlockById(blockId)
|
||||
.mutator.getWorkspace()
|
||||
.getBlocksByType(mutatorBlockType).length;
|
||||
},
|
||||
'controls_if_1',
|
||||
'controls_if_elseif',
|
||||
);
|
||||
|
||||
chai.assert.equal(
|
||||
numberOfIfElseBlocks,
|
||||
1,
|
||||
'Expected there to be one if_else block in mutator workspace',
|
||||
);
|
||||
|
||||
const focusedBlockType = await getSelectedBlockType(this.browser);
|
||||
chai.assert.equal(
|
||||
focusedBlockType,
|
||||
'controls_if_elseif',
|
||||
'Newly pasted block should be selected',
|
||||
);
|
||||
|
||||
// Check that there are no new blocks on the main workspace
|
||||
const numberOfIfElseBlocksOnMainWorkspace = await this.browser.execute(
|
||||
(mutatorBlockType) => {
|
||||
return Blockly.getMainWorkspace().getBlocksByType(mutatorBlockType)
|
||||
.length;
|
||||
},
|
||||
'controls_if_elseif',
|
||||
);
|
||||
chai.assert.equal(
|
||||
numberOfIfElseBlocksOnMainWorkspace,
|
||||
0,
|
||||
'Mutator blocks should not appear on main workspace',
|
||||
);
|
||||
});
|
||||
|
||||
test('Copy block from mutator flyout, paste to main workspace while mutator closed', async function () {
|
||||
// Load the start blocks
|
||||
await loadStartBlocks(this.browser);
|
||||
|
||||
// Open the controls_if mutator
|
||||
const block = await getBlockTypeFromWorkspace(
|
||||
this.browser,
|
||||
'controls_if',
|
||||
0,
|
||||
);
|
||||
await openMutatorForBlock(this.browser, block);
|
||||
|
||||
// Select the first block in the mutator flyout
|
||||
await this.browser.execute(
|
||||
(blockId, mutatorBlockType) => {
|
||||
const flyoutBlock = Blockly.getMainWorkspace()
|
||||
.getBlockById(blockId)
|
||||
.mutator.getWorkspace()
|
||||
.getFlyout()
|
||||
.getWorkspace()
|
||||
.getBlocksByType(mutatorBlockType)[0];
|
||||
|
||||
Blockly.getFocusManager().focusNode(flyoutBlock);
|
||||
},
|
||||
'controls_if_1',
|
||||
'controls_if_elseif',
|
||||
);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Copy
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Close the mutator flyout (calling this method on open mutator closes it)
|
||||
await openMutatorForBlock(this.browser, block);
|
||||
|
||||
// Click the main workspace
|
||||
await clickWorkspace(this.browser);
|
||||
|
||||
// Paste
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check that there are no new blocks on the main workspace
|
||||
const numberOfIfElseBlocksOnMainWorkspace = await this.browser.execute(
|
||||
(mutatorBlockType) => {
|
||||
return Blockly.getMainWorkspace().getBlocksByType(mutatorBlockType)
|
||||
.length;
|
||||
},
|
||||
'controls_if_elseif',
|
||||
);
|
||||
chai.assert.equal(
|
||||
numberOfIfElseBlocksOnMainWorkspace,
|
||||
0,
|
||||
'Mutator blocks should not appear on main workspace',
|
||||
);
|
||||
});
|
||||
|
||||
test('Copy workspace comment, paste to main workspace', async function () {
|
||||
// Add a workspace comment to the workspace
|
||||
await this.browser.execute(() => {
|
||||
const workspace = Blockly.getMainWorkspace();
|
||||
const json = {
|
||||
'workspaceComments': [
|
||||
{
|
||||
'height': 100,
|
||||
'width': 120,
|
||||
'id': 'workspace_comment_1',
|
||||
'x': 13,
|
||||
'y': -12,
|
||||
'text': 'This is a comment',
|
||||
},
|
||||
],
|
||||
};
|
||||
Blockly.serialization.workspaces.load(json, workspace);
|
||||
});
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Select the workspace comment
|
||||
await this.browser.execute(() => {
|
||||
const comment = Blockly.getMainWorkspace().getCommentById(
|
||||
'workspace_comment_1',
|
||||
);
|
||||
Blockly.getFocusManager().focusNode(comment);
|
||||
});
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Copy
|
||||
await this.browser.keys([Key.Ctrl, 'c']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Click the main workspace
|
||||
await clickWorkspace(this.browser);
|
||||
|
||||
// Paste
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check that there are 2 comments on the workspace
|
||||
const numberOfComments = await this.browser.execute(() => {
|
||||
return Blockly.getMainWorkspace().getTopComments().length;
|
||||
});
|
||||
chai.assert.equal(
|
||||
numberOfComments,
|
||||
2,
|
||||
'Expected 2 workspace comments after pasting',
|
||||
);
|
||||
});
|
||||
|
||||
test('Cut block from main workspace, paste to main workspace', async function () {
|
||||
await loadStartBlocks(this.browser);
|
||||
// Select and cut the "true" block
|
||||
await focusOnBlock(this.browser, 'logic_boolean_1');
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
await this.browser.keys([Key.Ctrl, 'x']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check that the "true" block was deleted
|
||||
const trueBlock = await this.browser.execute(() => {
|
||||
return Blockly.getMainWorkspace().getBlockById('logic_boolean_1') ?? null;
|
||||
});
|
||||
chai.assert.isNull(trueBlock);
|
||||
|
||||
// Check how many blocks there are before pasting
|
||||
const allBlocksBeforePaste = await getAllBlocks(this.browser);
|
||||
|
||||
// Paste the block while still in the main workspace
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check result
|
||||
const allBlocksAfterPaste = await getAllBlocks(this.browser);
|
||||
chai.assert.equal(
|
||||
allBlocksAfterPaste.length,
|
||||
allBlocksBeforePaste.length + 1,
|
||||
'Expected there to be one additional block after paste',
|
||||
);
|
||||
});
|
||||
|
||||
test('Cannot cut block from flyout', async function () {
|
||||
// Open flyout
|
||||
await getCategory(this.browser, 'Logic').then((category) =>
|
||||
category.click(),
|
||||
);
|
||||
|
||||
// Focus on first block in flyout
|
||||
await this.browser.execute(() => {
|
||||
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
|
||||
const block = ws.getBlocksByType('controls_if')[0];
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
});
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Cut
|
||||
await this.browser.keys([Key.Ctrl, 'x']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Select the main workspace
|
||||
await clickWorkspace(this.browser);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Paste
|
||||
await this.browser.keys([Key.Ctrl, 'v']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
|
||||
// Check that no block was pasted
|
||||
const allBlocks = await getAllBlocks(this.browser);
|
||||
chai.assert.equal(
|
||||
allBlocks.length,
|
||||
0,
|
||||
'Expected no blocks in the workspace because nothing to paste',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,23 @@ export const screenDirection = {
|
||||
LTR: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Focuses and selects a block with the provided ID.
|
||||
*
|
||||
* This throws an error if no block exists for the specified ID.
|
||||
*
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @param blockId The ID of the block to select.
|
||||
*/
|
||||
export async function focusOnBlock(browser, blockId) {
|
||||
return await browser.execute((blockId) => {
|
||||
const workspaceSvg = Blockly.getMainWorkspace();
|
||||
const block = workspaceSvg.getBlockById(blockId);
|
||||
if (!block) throw new Error(`No block found with ID: ${blockId}.`);
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
}, blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @return A Promise that resolves to the ID of the currently selected block.
|
||||
@@ -138,6 +155,17 @@ export async function getSelectedBlockId(browser) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @return A Promise that resolves to the ID of the currently selected block.
|
||||
*/
|
||||
export async function getSelectedBlockType(browser) {
|
||||
return await browser.execute(() => {
|
||||
// Note: selected is an ICopyable and I am assuming that it is a BlockSvg.
|
||||
return Blockly.common.getSelected()?.type;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @return A Promise that resolves to the selected block's root SVG element,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import * as chai from 'chai';
|
||||
import {Key} from 'webdriverio';
|
||||
import {
|
||||
dragBlockTypeFromFlyout,
|
||||
getBlockTypeFromCategory,
|
||||
getCategory,
|
||||
PAUSE_TIME,
|
||||
screenDirection,
|
||||
@@ -148,7 +148,12 @@ async function openCategories(browser, categoryList, directionMultiplier) {
|
||||
continue;
|
||||
}
|
||||
const blockType = await getNthBlockType(browser, categoryName, i);
|
||||
dragBlockTypeFromFlyout(browser, categoryName, blockType, 50, 20);
|
||||
const blockElem = await getBlockTypeFromCategory(
|
||||
browser,
|
||||
categoryName,
|
||||
blockType,
|
||||
);
|
||||
await blockElem.dragAndDrop({x: 50 * directionMultiplier, y: 20});
|
||||
await browser.pause(PAUSE_TIME);
|
||||
// Should be one top level block on the workspace.
|
||||
const topBlockCount = await browser.execute(() => {
|
||||
@@ -174,9 +179,9 @@ async function openCategories(browser, categoryList, directionMultiplier) {
|
||||
chai.assert.equal(failureCount, 0);
|
||||
}
|
||||
|
||||
// TODO (#9217) These take too long to run and are very flakey. Need to find a
|
||||
// better way to test whatever this is trying to test.
|
||||
suite.skip('Open toolbox categories', function () {
|
||||
// TODO (#9217) These take too long to run and are very flakey. Need to pull
|
||||
// these out into their own test runner.
|
||||
suite('Open toolbox categories', function () {
|
||||
this.timeout(0);
|
||||
|
||||
test('opening every toolbox category in the category toolbox in LTR', async function () {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import {Align} from '../../build/src/core/inputs/align.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 {assert} from '../../node_modules/chai/index.js';
|
||||
import {createRenderedBlock} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
createChangeListenerSpy,
|
||||
@@ -201,6 +201,35 @@ suite('Blocks', function () {
|
||||
|
||||
assertUnpluggedHealFailed(blocks);
|
||||
});
|
||||
test('Disconnect top of stack with immovable sibling', function () {
|
||||
this.blocks.B.setMovable(false);
|
||||
this.blocks.A.unplug(true);
|
||||
assert.equal(this.blocks.A.nextConnection.targetBlock(), this.blocks.B);
|
||||
assert.isNull(this.blocks.B.nextConnection.targetBlock());
|
||||
assert.isNull(this.blocks.C.previousConnection.targetBlock());
|
||||
});
|
||||
test('Heal with immovable sibling mid-stack', function () {
|
||||
const blockD = this.workspace.newBlock('stack_block', 'd');
|
||||
this.blocks.C.nextConnection.connect(blockD.previousConnection);
|
||||
this.blocks.C.setMovable(false);
|
||||
this.blocks.B.unplug(true);
|
||||
assert.equal(this.blocks.A.nextConnection.targetBlock(), blockD);
|
||||
assert.equal(this.blocks.B.nextConnection.targetBlock(), this.blocks.C);
|
||||
assert.isNull(this.blocks.C.nextConnection.targetBlock());
|
||||
});
|
||||
test('Heal with immovable sibling and shadow sibling mid-stack', function () {
|
||||
const blockD = this.workspace.newBlock('stack_block', 'd');
|
||||
const blockE = this.workspace.newBlock('stack_block', 'e');
|
||||
this.blocks.C.nextConnection.connect(blockD.previousConnection);
|
||||
blockD.nextConnection.connect(blockE.previousConnection);
|
||||
this.blocks.C.setMovable(false);
|
||||
blockD.setShadow(true);
|
||||
this.blocks.B.unplug(true);
|
||||
assert.equal(this.blocks.A.nextConnection.targetBlock(), blockE);
|
||||
assert.equal(this.blocks.B.nextConnection.targetBlock(), this.blocks.C);
|
||||
assert.equal(this.blocks.C.nextConnection.targetBlock(), blockD);
|
||||
assert.isNull(blockD.nextConnection.targetBlock());
|
||||
});
|
||||
test('Child is shadow', function () {
|
||||
const blocks = this.blocks;
|
||||
blocks.C.setShadow(true);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import {ConnectionType} from '../../../build/src/core/connection_type.js';
|
||||
import {assert} from '../../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../../node_modules/chai/index.js';
|
||||
import {defineStatementBlock} from '../test_helpers/block_definitions.js';
|
||||
import {runSerializationTestSuite} from '../test_helpers/serialization.js';
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import * as eventUtils from '../../../build/src/core/events/utils.js';
|
||||
import {assert} from '../../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../../node_modules/chai/index.js';
|
||||
import {runSerializationTestSuite} from '../test_helpers/serialization.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import * as Blockly from '../../../build/src/core/blockly.js';
|
||||
import {assert} from '../../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import * as Blockly from '../../../build/src/core/blockly.js';
|
||||
import {assert} from '../../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../../node_modules/chai/index.js';
|
||||
import {defineRowBlock} from '../test_helpers/block_definitions.js';
|
||||
import {
|
||||
assertCallBlockStructure,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import {nameUsedWithConflictingParam} from '../../../build/src/core/variables.js';
|
||||
import {assert} from '../../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../../node_modules/chai/index.js';
|
||||
import {
|
||||
MockParameterModelWithVar,
|
||||
MockProcedureModel,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
assertEventFired,
|
||||
createChangeListenerSpy,
|
||||
@@ -61,6 +61,31 @@ suite('Clipboard', function () {
|
||||
);
|
||||
});
|
||||
|
||||
test('pasting blocks includes next blocks if requested', function () {
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
{
|
||||
'type': 'controls_if',
|
||||
'id': 'blockId',
|
||||
'next': {
|
||||
'block': {
|
||||
'type': 'controls_if',
|
||||
'id': 'blockId2',
|
||||
},
|
||||
},
|
||||
},
|
||||
this.workspace,
|
||||
);
|
||||
assert.equal(this.workspace.getBlocksByType('controls_if').length, 2);
|
||||
// Both blocks should be copied
|
||||
const data = block.toCopyData(true);
|
||||
this.clock.runAll();
|
||||
|
||||
Blockly.clipboard.paste(data, this.workspace);
|
||||
this.clock.runAll();
|
||||
// After pasting, we should have gone from 2 to 4 blocks.
|
||||
assert.equal(this.workspace.getBlocksByType('controls_if').length, 4);
|
||||
});
|
||||
|
||||
test('copied from a mutator pastes them into the mutator', async function () {
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
{
|
||||
@@ -132,6 +157,34 @@ suite('Clipboard', function () {
|
||||
);
|
||||
});
|
||||
|
||||
test('pasted blocks are bumped to not overlap in RTL', function () {
|
||||
this.workspace.dispose();
|
||||
this.workspace = Blockly.inject('blocklyDiv', {rtl: true});
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
{
|
||||
'type': 'controls_if',
|
||||
'x': 38,
|
||||
'y': 13,
|
||||
},
|
||||
this.workspace,
|
||||
);
|
||||
const data = block.toCopyData();
|
||||
|
||||
const newBlock = Blockly.clipboard.paste(data, this.workspace);
|
||||
const oldBlockXY = block.getRelativeToSurfaceXY();
|
||||
assert.deepEqual(
|
||||
newBlock.getRelativeToSurfaceXY(),
|
||||
new Blockly.utils.Coordinate(
|
||||
oldBlockXY.x - Blockly.config.snapRadius,
|
||||
oldBlockXY.y + Blockly.config.snapRadius * 2,
|
||||
),
|
||||
);
|
||||
|
||||
// Restore an LTR workspace.
|
||||
this.workspace.dispose();
|
||||
this.workspace = Blockly.inject('blocklyDiv');
|
||||
});
|
||||
|
||||
test('pasted blocks are bumped to be outside the connection snap radius', function () {
|
||||
Blockly.serialization.workspaces.load(
|
||||
{
|
||||
@@ -183,5 +236,28 @@ suite('Clipboard', function () {
|
||||
new Blockly.utils.Coordinate(40, 40),
|
||||
);
|
||||
});
|
||||
|
||||
test('pasted comments are bumped to not overlap in RTL', function () {
|
||||
this.workspace.dispose();
|
||||
this.workspace = Blockly.inject('blocklyDiv', {rtl: true});
|
||||
Blockly.Xml.domToWorkspace(
|
||||
Blockly.utils.xml.textToDom(
|
||||
'<xml><comment id="test" x=10 y=10/></xml>',
|
||||
),
|
||||
this.workspace,
|
||||
);
|
||||
const comment = this.workspace.getTopComments(false)[0];
|
||||
const data = comment.toCopyData();
|
||||
|
||||
const newComment = Blockly.clipboard.paste(data, this.workspace);
|
||||
const oldCommentXY = comment.getRelativeToSurfaceXY();
|
||||
assert.deepEqual(
|
||||
newComment.getRelativeToSurfaceXY(),
|
||||
new Blockly.utils.Coordinate(oldCommentXY.x - 30, oldCommentXY.y + 30),
|
||||
);
|
||||
// Restore an LTR workspace.
|
||||
this.workspace.dispose();
|
||||
this.workspace = Blockly.inject('blocklyDiv');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import {EventType} from '../../build/src/core/events/type.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {assertEventFired} from './test_helpers/events.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import {ConnectionType} from '../../build/src/core/connection_type.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -6,7 +6,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 {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
defineRowBlock,
|
||||
defineStackBlock,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import {callbackFactory} from '../../build/src/core/contextmenu.js';
|
||||
import * as xmlUtils from '../../build/src/core/utils/xml.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {createRenderedBlock} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import {Rect} from '../../build/src/core/utils/rect.js';
|
||||
import * as style from '../../build/src/core/utils/style.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {defineMutatorBlocks} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import {EventType} from '../../build/src/core/events/type.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {defineRowBlock} from './test_helpers/block_definitions.js';
|
||||
import {assertEventFired} from './test_helpers/events.js';
|
||||
import {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {defineRowBlock} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {defineRowBlock} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {defineRowBlock} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {defineMutatorBlocks} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {defineRowBlock} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {defineRowBlock} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as Blockly from '../../build/src/core/blockly.js';
|
||||
import * as eventUtils from '../../build/src/core/events/utils.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
assertEventEquals,
|
||||
assertNthCallEventArgEquals,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {assert} from '../../node_modules/chai/index.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user