mirror of
https://github.com/google/blockly.git
synced 2026-01-29 19:50:10 +01:00
release: merge develop into rv/v12.2.0
This commit is contained in:
@@ -299,8 +299,19 @@ export class BlockSvg
|
||||
}
|
||||
|
||||
const oldXY = this.getRelativeToSurfaceXY();
|
||||
const focusedNode = getFocusManager().getFocusedNode();
|
||||
const restoreFocus = this.getSvgRoot().contains(
|
||||
focusedNode?.getFocusableElement() ?? null,
|
||||
);
|
||||
if (newParent) {
|
||||
(newParent as BlockSvg).getSvgRoot().appendChild(svgRoot);
|
||||
// appendChild() clears focus state, so re-focus the previously focused
|
||||
// node in case it was this block and would otherwise lose its focus. Once
|
||||
// Element.moveBefore() has better browser support, it should be used
|
||||
// instead.
|
||||
if (restoreFocus && focusedNode) {
|
||||
getFocusManager().focusNode(focusedNode);
|
||||
}
|
||||
} else if (oldParent) {
|
||||
// If we are losing a parent, we want to move our DOM element to the
|
||||
// root of the workspace. Try to insert it before any top-level
|
||||
@@ -319,6 +330,13 @@ export class BlockSvg
|
||||
canvas.insertBefore(svgRoot, draggingBlockElement);
|
||||
} else {
|
||||
canvas.appendChild(svgRoot);
|
||||
// appendChild() clears focus state, so re-focus the previously focused
|
||||
// node in case it was this block and would otherwise lose its focus. Once
|
||||
// Element.moveBefore() has better browser support, it should be used
|
||||
// instead.
|
||||
if (restoreFocus && focusedNode) {
|
||||
getFocusManager().focusNode(focusedNode);
|
||||
}
|
||||
}
|
||||
this.translate(oldXY.x, oldXY.y);
|
||||
}
|
||||
@@ -849,10 +867,30 @@ export class BlockSvg
|
||||
Tooltip.dispose();
|
||||
ContextMenu.hide();
|
||||
|
||||
// If this block was focused, focus its parent or workspace instead.
|
||||
// If this block (or a descendant) was focused, focus its parent or
|
||||
// workspace instead.
|
||||
const focusManager = getFocusManager();
|
||||
if (focusManager.getFocusedNode() === this) {
|
||||
const parent = this.getParent();
|
||||
if (
|
||||
this.getSvgRoot().contains(
|
||||
focusManager.getFocusedNode()?.getFocusableElement() ?? null,
|
||||
)
|
||||
) {
|
||||
let parent: BlockSvg | undefined | null = this.getParent();
|
||||
if (!parent) {
|
||||
// In some cases, blocks are disconnected from their parents before
|
||||
// being deleted. Attempt to infer if there was a parent by checking
|
||||
// for a connection within a radius of 0. Even if this wasn't a parent,
|
||||
// it must be adjacent to this block and so is as good an option as any
|
||||
// to focus after deleting.
|
||||
const connection = this.outputConnection ?? this.previousConnection;
|
||||
if (connection) {
|
||||
const targetConnection = connection.closest(
|
||||
0,
|
||||
new Coordinate(0, 0),
|
||||
).connection;
|
||||
parent = targetConnection?.getSourceBlock();
|
||||
}
|
||||
}
|
||||
if (parent) {
|
||||
focusManager.focusNode(parent);
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
|
||||
import * as registry from './clipboard/registry.js';
|
||||
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
|
||||
import {isSelectable} from './interfaces/i_selectable.js';
|
||||
import * as globalRegistry from './registry.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import {WorkspaceSvg} from './workspace_svg.js';
|
||||
@@ -18,18 +19,119 @@ let stashedCopyData: ICopyData | null = null;
|
||||
|
||||
let stashedWorkspace: WorkspaceSvg | null = null;
|
||||
|
||||
let stashedCoordinates: Coordinate | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Private version of copy for stubbing in tests.
|
||||
* Copy a copyable item, and record its data and the workspace it was
|
||||
* copied from.
|
||||
*
|
||||
* This function does not perform any checks to ensure the copy
|
||||
* should be allowed, e.g. to ensure the block is deletable. Such
|
||||
* checks should be done before calling this function.
|
||||
*
|
||||
* Note that if the copyable item is not an `ISelectable` or its
|
||||
* `workspace` property is not a `WorkspaceSvg`, the copy will be
|
||||
* successful, but there will be no saved workspace data. This will
|
||||
* impact the ability to paste the data unless you explictily pass
|
||||
* a workspace into the paste method.
|
||||
*
|
||||
* @param toCopy item to copy.
|
||||
* @param location location to save as a potential paste location.
|
||||
* @returns the copied data if copy was successful, otherwise null.
|
||||
*/
|
||||
function copyInternal<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
|
||||
export function copy<T extends ICopyData>(
|
||||
toCopy: ICopyable<T>,
|
||||
location?: Coordinate,
|
||||
): T | null {
|
||||
const data = toCopy.toCopyData();
|
||||
stashedCopyData = data;
|
||||
stashedWorkspace = (toCopy as any).workspace ?? null;
|
||||
if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) {
|
||||
stashedWorkspace = toCopy.workspace;
|
||||
} else {
|
||||
stashedWorkspace = null;
|
||||
}
|
||||
|
||||
stashedCoordinates = location;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste a pasteable element into the workspace.
|
||||
* Gets the copy data for the last item copied. This is useful if you
|
||||
* are implementing custom copy/paste behavior. If you want the default
|
||||
* behavior, just use the copy and paste methods directly.
|
||||
*
|
||||
* @returns copy data for the last item copied, or null if none set.
|
||||
*/
|
||||
export function getLastCopiedData() {
|
||||
return stashedCopyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last copied item. You should call this method if you implement
|
||||
* custom copy behavior, so that other callers are working with the correct
|
||||
* data. This method is called automatically if you use the built-in copy
|
||||
* method.
|
||||
*
|
||||
* @param copyData copy data for the last item copied.
|
||||
*/
|
||||
export function setLastCopiedData(copyData: ICopyData) {
|
||||
stashedCopyData = copyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the workspace that was last copied from. This is useful if you
|
||||
* are implementing custom copy/paste behavior and want to paste on the
|
||||
* same workspace that was copied from. If you want the default behavior,
|
||||
* just use the copy and paste methods directly.
|
||||
*
|
||||
* @returns workspace that was last copied from, or null if none set.
|
||||
*/
|
||||
export function getLastCopiedWorkspace() {
|
||||
return stashedWorkspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the workspace that was last copied from. You should call this method
|
||||
* if you implement custom copy behavior, so that other callers are working
|
||||
* with the correct data. This method is called automatically if you use the
|
||||
* built-in copy method.
|
||||
*
|
||||
* @param workspace workspace that was last copied from.
|
||||
*/
|
||||
export function setLastCopiedWorkspace(workspace: WorkspaceSvg) {
|
||||
stashedWorkspace = workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the location that was last copied from. This is useful if you
|
||||
* are implementing custom copy/paste behavior. If you want the
|
||||
* default behavior, just use the copy and paste methods directly.
|
||||
*
|
||||
* @returns last saved location, or null if none set.
|
||||
*/
|
||||
export function getLastCopiedLocation() {
|
||||
return stashedCoordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the location that was last copied from. You should call this method
|
||||
* if you implement custom copy behavior, so that other callers are working
|
||||
* with the correct data. This method is called automatically if you use the
|
||||
* built-in copy method.
|
||||
*
|
||||
* @param location last saved location, which can be used to paste at.
|
||||
*/
|
||||
export function setLastCopiedLocation(location: Coordinate) {
|
||||
stashedCoordinates = location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste a pasteable element into the given workspace.
|
||||
*
|
||||
* This function does not perform any checks to ensure the paste
|
||||
* is allowed, e.g. that the workspace is rendered or the block
|
||||
* is pasteable. Such checks should be done before calling this
|
||||
* function.
|
||||
*
|
||||
* @param copyData The data to paste into the workspace.
|
||||
* @param workspace The workspace to paste the data into.
|
||||
@@ -43,7 +145,7 @@ export function paste<T extends ICopyData>(
|
||||
): ICopyable<T> | null;
|
||||
|
||||
/**
|
||||
* Pastes the last copied ICopyable into the workspace.
|
||||
* Pastes the last copied ICopyable into the last copied-from workspace.
|
||||
*
|
||||
* @returns the pasted thing if the paste was successful, null otherwise.
|
||||
*/
|
||||
@@ -65,7 +167,7 @@ export function paste<T extends ICopyData>(
|
||||
): ICopyable<ICopyData> | null {
|
||||
if (!copyData || !workspace) {
|
||||
if (!stashedCopyData || !stashedWorkspace) return null;
|
||||
return pasteFromData(stashedCopyData, stashedWorkspace);
|
||||
return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates);
|
||||
}
|
||||
return pasteFromData(copyData, workspace, coordinate);
|
||||
}
|
||||
@@ -85,31 +187,11 @@ function pasteFromData<T extends ICopyData>(
|
||||
): ICopyable<T> | null {
|
||||
workspace = workspace.isMutator
|
||||
? workspace
|
||||
: (workspace.getRootWorkspace() ?? workspace);
|
||||
: // Use the parent workspace if it exists (e.g. for pasting into flyouts)
|
||||
(workspace.options.parentWorkspace ?? workspace);
|
||||
return (globalRegistry
|
||||
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
|
||||
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private version of duplicate for stubbing in tests.
|
||||
*/
|
||||
function duplicateInternal<
|
||||
U extends ICopyData,
|
||||
T extends ICopyable<U> & IHasWorkspace,
|
||||
>(toDuplicate: T): T | null {
|
||||
const data = toDuplicate.toCopyData();
|
||||
if (!data) return null;
|
||||
return paste(data, toDuplicate.workspace) as T;
|
||||
}
|
||||
|
||||
interface IHasWorkspace {
|
||||
workspace: WorkspaceSvg;
|
||||
}
|
||||
|
||||
export const TEST_ONLY = {
|
||||
duplicateInternal,
|
||||
copyInternal,
|
||||
};
|
||||
|
||||
export {BlockCopyData, BlockPaster, registry};
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js';
|
||||
export {CommentBarButton} from './comments/comment_bar_button.js';
|
||||
export {CommentEditor} from './comments/comment_editor.js';
|
||||
export {CommentView} from './comments/comment_view.js';
|
||||
export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js';
|
||||
export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
|
||||
export {WorkspaceComment} from './comments/workspace_comment.js';
|
||||
|
||||
101
core/comments/collapse_comment_bar_button.ts
Normal file
101
core/comments/collapse_comment_bar_button.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {CommentBarButton} from './comment_bar_button.js';
|
||||
|
||||
/**
|
||||
* Magic string appended to the comment ID to create a unique ID for this button.
|
||||
*/
|
||||
export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER =
|
||||
'_collapse_bar_button';
|
||||
|
||||
/**
|
||||
* Button that toggles the collapsed state of a comment.
|
||||
*/
|
||||
export class CollapseCommentBarButton extends CommentBarButton {
|
||||
/**
|
||||
* Opaque ID used to unbind event handlers during disposal.
|
||||
*/
|
||||
private readonly bindId: browserEvents.Data;
|
||||
|
||||
/**
|
||||
* SVG image displayed on this button.
|
||||
*/
|
||||
protected override readonly icon: SVGImageElement;
|
||||
|
||||
/**
|
||||
* Creates a new CollapseCommentBarButton instance.
|
||||
*
|
||||
* @param id The ID of this button's parent comment.
|
||||
* @param workspace The workspace this button's parent comment is displayed on.
|
||||
* @param container An SVG group that this button should be a child of.
|
||||
*/
|
||||
constructor(
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
) {
|
||||
super(id, workspace, container);
|
||||
|
||||
this.icon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
{
|
||||
'class': 'blocklyFoldoutIcon',
|
||||
'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`,
|
||||
'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
|
||||
},
|
||||
this.container,
|
||||
);
|
||||
this.bindId = browserEvents.conditionalBind(
|
||||
this.icon,
|
||||
'pointerdown',
|
||||
this,
|
||||
this.performAction.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of this button.
|
||||
*/
|
||||
dispose() {
|
||||
browserEvents.unbind(this.bindId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the positioning of this button within its container.
|
||||
*/
|
||||
override reposition() {
|
||||
const margin = this.getMargin();
|
||||
this.icon.setAttribute('y', `${margin}`);
|
||||
this.icon.setAttribute('x', `${margin}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the collapsed state of the parent comment.
|
||||
*
|
||||
* @param e The event that triggered this action.
|
||||
*/
|
||||
override performAction(e?: Event) {
|
||||
touch.clearTouchIdentifier();
|
||||
|
||||
const comment = this.getParentComment();
|
||||
comment.view.bringToFront();
|
||||
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
comment.setCollapsed(!comment.isCollapsed());
|
||||
this.workspace.hideChaff();
|
||||
|
||||
e?.stopPropagation();
|
||||
}
|
||||
}
|
||||
105
core/comments/comment_bar_button.ts
Normal file
105
core/comments/comment_bar_button.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {Rect} from '../utils/rect.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
|
||||
|
||||
/**
|
||||
* Button displayed on a comment's top bar.
|
||||
*/
|
||||
export abstract class CommentBarButton implements IFocusableNode {
|
||||
/**
|
||||
* SVG image displayed on this button.
|
||||
*/
|
||||
protected abstract readonly icon: SVGImageElement;
|
||||
|
||||
/**
|
||||
* Creates a new CommentBarButton instance.
|
||||
*
|
||||
* @param id The ID of this button's parent comment.
|
||||
* @param workspace The workspace this button's parent comment is on.
|
||||
* @param container An SVG group that this button should be a child of.
|
||||
*/
|
||||
constructor(
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns whether or not this button is currently visible.
|
||||
*/
|
||||
isVisible(): boolean {
|
||||
return this.icon.checkVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent comment of this comment bar button.
|
||||
*/
|
||||
getParentComment(): RenderedWorkspaceComment {
|
||||
const comment = this.workspace.getCommentById(this.id);
|
||||
if (!comment) {
|
||||
throw new Error(
|
||||
`Comment bar button ${this.id} has no corresponding comment`,
|
||||
);
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
/** Adjusts the position of this button within its parent container. */
|
||||
abstract reposition(): void;
|
||||
|
||||
/** Perform the action this button should take when it is acted on. */
|
||||
abstract performAction(e?: Event): void;
|
||||
|
||||
/**
|
||||
* Returns the dimensions of this button in workspace coordinates.
|
||||
*
|
||||
* @param includeMargin True to include the margin when calculating the size.
|
||||
* @returns The size of this button.
|
||||
*/
|
||||
getSize(includeMargin = false): Rect {
|
||||
const bounds = this.icon.getBBox();
|
||||
const rect = Rect.from(bounds);
|
||||
if (includeMargin) {
|
||||
const margin = this.getMargin();
|
||||
rect.left -= margin;
|
||||
rect.top -= margin;
|
||||
rect.bottom += margin;
|
||||
rect.right += margin;
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
|
||||
/** Returns the margin in workspace coordinates surrounding this button. */
|
||||
getMargin(): number {
|
||||
return (this.container.getBBox().height - this.icon.getBBox().height) / 2;
|
||||
}
|
||||
|
||||
/** Returns a DOM element representing this button that can receive focus. */
|
||||
getFocusableElement() {
|
||||
return this.icon;
|
||||
}
|
||||
|
||||
/** Returns the workspace this button is a child of. */
|
||||
getFocusableTree() {
|
||||
return this.workspace;
|
||||
}
|
||||
|
||||
/** Called when this button's focusable DOM element gains focus. */
|
||||
onNodeFocus() {}
|
||||
|
||||
/** Called when this button's focusable DOM element loses focus. */
|
||||
onNodeBlur() {}
|
||||
|
||||
/** Returns whether this button can be focused. True if it is visible. */
|
||||
canBeFocused() {
|
||||
return this.isVisible();
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export class CommentEditor implements IFocusableNode {
|
||||
dom.HTML_NS,
|
||||
'textarea',
|
||||
) as HTMLTextAreaElement;
|
||||
this.textArea.setAttribute('tabindex', '-1');
|
||||
dom.addClass(this.textArea, 'blocklyCommentText');
|
||||
dom.addClass(this.textArea, 'blocklyTextarea');
|
||||
dom.addClass(this.textArea, 'blocklyText');
|
||||
|
||||
@@ -16,14 +16,17 @@ import * as drag from '../utils/drag.js';
|
||||
import {Size} from '../utils/size.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {CollapseCommentBarButton} from './collapse_comment_bar_button.js';
|
||||
import {CommentBarButton} from './comment_bar_button.js';
|
||||
import {CommentEditor} from './comment_editor.js';
|
||||
import {DeleteCommentBarButton} from './delete_comment_bar_button.js';
|
||||
|
||||
export class CommentView implements IRenderedElement {
|
||||
/** The root group element of the comment view. */
|
||||
private svgRoot: SVGGElement;
|
||||
|
||||
/**
|
||||
* The svg rect element that we use to create a hightlight around the comment.
|
||||
* The SVG rect element that we use to create a highlight around the comment.
|
||||
*/
|
||||
private highlightRect: SVGRectElement;
|
||||
|
||||
@@ -33,11 +36,11 @@ export class CommentView implements IRenderedElement {
|
||||
/** The rect background for the top bar. */
|
||||
private topBarBackground: SVGRectElement;
|
||||
|
||||
/** The delete icon that goes in the top bar. */
|
||||
private deleteIcon: SVGImageElement;
|
||||
/** The delete button that goes in the top bar. */
|
||||
private deleteButton: DeleteCommentBarButton;
|
||||
|
||||
/** The foldout icon that goes in the top bar. */
|
||||
private foldoutIcon: SVGImageElement;
|
||||
/** The foldout button that goes in the top bar. */
|
||||
private foldoutButton: CollapseCommentBarButton;
|
||||
|
||||
/** The text element that goes in the top bar. */
|
||||
private textPreview: SVGTextElement;
|
||||
@@ -99,7 +102,7 @@ export class CommentView implements IRenderedElement {
|
||||
|
||||
constructor(
|
||||
readonly workspace: WorkspaceSvg,
|
||||
private commentId?: string,
|
||||
private commentId: string,
|
||||
) {
|
||||
this.svgRoot = dom.createSvgElement(Svg.G, {
|
||||
'class': 'blocklyComment blocklyEditable blocklyDraggable',
|
||||
@@ -110,11 +113,11 @@ export class CommentView implements IRenderedElement {
|
||||
({
|
||||
topBarGroup: this.topBarGroup,
|
||||
topBarBackground: this.topBarBackground,
|
||||
deleteIcon: this.deleteIcon,
|
||||
foldoutIcon: this.foldoutIcon,
|
||||
deleteButton: this.deleteButton,
|
||||
foldoutButton: this.foldoutButton,
|
||||
textPreview: this.textPreview,
|
||||
textPreviewNode: this.textPreviewNode,
|
||||
} = this.createTopBar(this.svgRoot, workspace));
|
||||
} = this.createTopBar(this.svgRoot));
|
||||
|
||||
this.commentEditor = this.createTextArea();
|
||||
|
||||
@@ -147,14 +150,11 @@ export class CommentView implements IRenderedElement {
|
||||
* Creates the top bar and the elements visually within it.
|
||||
* Registers event listeners.
|
||||
*/
|
||||
private createTopBar(
|
||||
svgRoot: SVGGElement,
|
||||
workspace: WorkspaceSvg,
|
||||
): {
|
||||
private createTopBar(svgRoot: SVGGElement): {
|
||||
topBarGroup: SVGGElement;
|
||||
topBarBackground: SVGRectElement;
|
||||
deleteIcon: SVGImageElement;
|
||||
foldoutIcon: SVGImageElement;
|
||||
deleteButton: DeleteCommentBarButton;
|
||||
foldoutButton: CollapseCommentBarButton;
|
||||
textPreview: SVGTextElement;
|
||||
textPreviewNode: Text;
|
||||
} {
|
||||
@@ -172,22 +172,14 @@ export class CommentView implements IRenderedElement {
|
||||
},
|
||||
topBarGroup,
|
||||
);
|
||||
// TODO: Before merging, does this mean to override an individual image,
|
||||
// folks need to replace the whole media folder?
|
||||
const deleteIcon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
{
|
||||
'class': 'blocklyDeleteIcon',
|
||||
'href': `${workspace.options.pathToMedia}delete-icon.svg`,
|
||||
},
|
||||
const deleteButton = new DeleteCommentBarButton(
|
||||
this.commentId,
|
||||
this.workspace,
|
||||
topBarGroup,
|
||||
);
|
||||
const foldoutIcon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
{
|
||||
'class': 'blocklyFoldoutIcon',
|
||||
'href': `${workspace.options.pathToMedia}foldout-icon.svg`,
|
||||
},
|
||||
const foldoutButton = new CollapseCommentBarButton(
|
||||
this.commentId,
|
||||
this.workspace,
|
||||
topBarGroup,
|
||||
);
|
||||
const textPreview = dom.createSvgElement(
|
||||
@@ -200,27 +192,11 @@ export class CommentView implements IRenderedElement {
|
||||
const textPreviewNode = document.createTextNode('');
|
||||
textPreview.appendChild(textPreviewNode);
|
||||
|
||||
// TODO(toychest): Triggering this on pointerdown means that we can't start
|
||||
// drags on the foldout icon. We need to open up the gesture system
|
||||
// to fix this.
|
||||
browserEvents.conditionalBind(
|
||||
foldoutIcon,
|
||||
'pointerdown',
|
||||
this,
|
||||
this.onFoldoutDown,
|
||||
);
|
||||
browserEvents.conditionalBind(
|
||||
deleteIcon,
|
||||
'pointerdown',
|
||||
this,
|
||||
this.onDeleteDown,
|
||||
);
|
||||
|
||||
return {
|
||||
topBarGroup,
|
||||
topBarBackground,
|
||||
deleteIcon,
|
||||
foldoutIcon,
|
||||
deleteButton,
|
||||
foldoutButton,
|
||||
textPreview,
|
||||
textPreviewNode,
|
||||
};
|
||||
@@ -300,15 +276,10 @@ export class CommentView implements IRenderedElement {
|
||||
*/
|
||||
setSizeWithoutFiringEvents(size: Size) {
|
||||
const topBarSize = this.topBarBackground.getBBox();
|
||||
const deleteSize = this.deleteIcon.getBBox();
|
||||
const foldoutSize = this.foldoutIcon.getBBox();
|
||||
const textPreviewSize = this.textPreview.getBBox();
|
||||
const resizeSize = this.resizeHandle.getBBox();
|
||||
|
||||
size = Size.max(
|
||||
size,
|
||||
this.calcMinSize(topBarSize, foldoutSize, deleteSize),
|
||||
);
|
||||
size = Size.max(size, this.calcMinSize(topBarSize));
|
||||
this.size = size;
|
||||
|
||||
this.svgRoot.setAttribute('height', `${size.height}`);
|
||||
@@ -317,15 +288,9 @@ export class CommentView implements IRenderedElement {
|
||||
this.updateHighlightRect(size);
|
||||
this.updateTopBarSize(size);
|
||||
this.commentEditor.updateSize(size, topBarSize);
|
||||
this.updateDeleteIconPosition(size, topBarSize, deleteSize);
|
||||
this.updateFoldoutIconPosition(topBarSize, foldoutSize);
|
||||
this.updateTextPreviewSize(
|
||||
size,
|
||||
topBarSize,
|
||||
textPreviewSize,
|
||||
deleteSize,
|
||||
resizeSize,
|
||||
);
|
||||
this.deleteButton.reposition();
|
||||
this.foldoutButton.reposition();
|
||||
this.updateTextPreviewSize(size, topBarSize, textPreviewSize);
|
||||
this.updateResizeHandlePosition(size, resizeSize);
|
||||
}
|
||||
|
||||
@@ -347,25 +312,18 @@ export class CommentView implements IRenderedElement {
|
||||
*
|
||||
* The minimum height is based on the height of the top bar.
|
||||
*/
|
||||
private calcMinSize(
|
||||
topBarSize: Size,
|
||||
foldoutSize: Size,
|
||||
deleteSize: Size,
|
||||
): Size {
|
||||
private calcMinSize(topBarSize: Size): Size {
|
||||
this.updateTextPreview(this.commentEditor.getText() ?? '');
|
||||
const textPreviewWidth = dom.getTextWidth(this.textPreview);
|
||||
|
||||
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
|
||||
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
|
||||
|
||||
let width = textPreviewWidth;
|
||||
if (this.foldoutIcon.checkVisibility()) {
|
||||
width += foldoutSize.width + foldoutMargin * 2;
|
||||
if (this.foldoutButton.isVisible()) {
|
||||
width += this.foldoutButton.getSize(true).getWidth();
|
||||
} else if (textPreviewWidth) {
|
||||
width += 4; // Arbitrary margin before text.
|
||||
}
|
||||
if (this.deleteIcon.checkVisibility()) {
|
||||
width += deleteSize.width + deleteMargin * 2;
|
||||
if (this.deleteButton.isVisible()) {
|
||||
width += this.deleteButton.getSize(true).getWidth();
|
||||
} else if (textPreviewWidth) {
|
||||
width += 4; // Arbitrary margin after text.
|
||||
}
|
||||
@@ -376,16 +334,6 @@ export class CommentView implements IRenderedElement {
|
||||
return new Size(width, height);
|
||||
}
|
||||
|
||||
/** Calculates the margin that should exist around the delete icon. */
|
||||
private calcDeleteMargin(topBarSize: Size, deleteSize: Size) {
|
||||
return (topBarSize.height - deleteSize.height) / 2;
|
||||
}
|
||||
|
||||
/** Calculates the margin that should exist around the foldout icon. */
|
||||
private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) {
|
||||
return (topBarSize.height - foldoutSize.height) / 2;
|
||||
}
|
||||
|
||||
/** Updates the size of the highlight rect to reflect the new size. */
|
||||
private updateHighlightRect(size: Size) {
|
||||
this.highlightRect.setAttribute('height', `${size.height}`);
|
||||
@@ -400,31 +348,6 @@ export class CommentView implements IRenderedElement {
|
||||
this.topBarBackground.setAttribute('width', `${size.width}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the delete icon elements to reflect the new size.
|
||||
*/
|
||||
private updateDeleteIconPosition(
|
||||
size: Size,
|
||||
topBarSize: Size,
|
||||
deleteSize: Size,
|
||||
) {
|
||||
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
|
||||
this.deleteIcon.setAttribute('y', `${deleteMargin}`);
|
||||
this.deleteIcon.setAttribute(
|
||||
'x',
|
||||
`${size.width - deleteSize.width - deleteMargin}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the foldout icon elements to reflect the new size.
|
||||
*/
|
||||
private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) {
|
||||
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
|
||||
this.foldoutIcon.setAttribute('y', `${foldoutMargin}`);
|
||||
this.foldoutIcon.setAttribute('x', `${foldoutMargin}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the size and position of the text preview elements to reflect the new size.
|
||||
*/
|
||||
@@ -432,25 +355,14 @@ export class CommentView implements IRenderedElement {
|
||||
size: Size,
|
||||
topBarSize: Size,
|
||||
textPreviewSize: Size,
|
||||
deleteSize: Size,
|
||||
foldoutSize: Size,
|
||||
) {
|
||||
const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2;
|
||||
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
|
||||
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
|
||||
const foldoutSize = this.foldoutButton.getSize(true);
|
||||
const deleteSize = this.deleteButton.getSize(true);
|
||||
|
||||
const textPreviewWidth =
|
||||
size.width -
|
||||
foldoutSize.width -
|
||||
foldoutMargin * 2 -
|
||||
deleteSize.width -
|
||||
deleteMargin * 2;
|
||||
this.textPreview.setAttribute(
|
||||
'x',
|
||||
`${
|
||||
foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1)
|
||||
}`,
|
||||
);
|
||||
size.width - foldoutSize.getWidth() - deleteSize.getWidth();
|
||||
this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`);
|
||||
this.textPreview.setAttribute(
|
||||
'y',
|
||||
`${textPreviewMargin + textPreviewSize.height / 2}`,
|
||||
@@ -601,25 +513,6 @@ export class CommentView implements IRenderedElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the collapsedness of the block when we receive a pointer down
|
||||
* event on the foldout icon.
|
||||
*/
|
||||
private onFoldoutDown(e: PointerEvent) {
|
||||
touch.clearTouchIdentifier();
|
||||
this.bringToFront();
|
||||
if (browserEvents.isRightButton(e)) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCollapsed(!this.collapsed);
|
||||
|
||||
this.workspace.hideChaff();
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/** Returns true if the comment is currently editable. */
|
||||
isEditable(): boolean {
|
||||
return this.editable;
|
||||
@@ -692,7 +585,7 @@ export class CommentView implements IRenderedElement {
|
||||
}
|
||||
|
||||
/** Brings the workspace comment to the front of its layer. */
|
||||
private bringToFront() {
|
||||
bringToFront() {
|
||||
const parent = this.svgRoot.parentNode;
|
||||
const childNodes = parent!.childNodes;
|
||||
// Avoid moving the comment if it's already at the bottom.
|
||||
@@ -719,6 +612,8 @@ export class CommentView implements IRenderedElement {
|
||||
/** Disposes of this comment view. */
|
||||
dispose() {
|
||||
this.disposing = true;
|
||||
this.foldoutButton.dispose();
|
||||
this.deleteButton.dispose();
|
||||
dom.removeNode(this.svgRoot);
|
||||
// Loop through listeners backwards in case they remove themselves.
|
||||
for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
|
||||
@@ -749,6 +644,13 @@ export class CommentView implements IRenderedElement {
|
||||
removeDisposeListener(listener: () => void) {
|
||||
this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
getCommentBarButtons(): CommentBarButton[] {
|
||||
return [this.foldoutButton, this.deleteButton];
|
||||
}
|
||||
}
|
||||
|
||||
css.register(`
|
||||
|
||||
104
core/comments/delete_comment_bar_button.ts
Normal file
104
core/comments/delete_comment_bar_button.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {CommentBarButton} from './comment_bar_button.js';
|
||||
|
||||
/**
|
||||
* Magic string appended to the comment ID to create a unique ID for this button.
|
||||
*/
|
||||
export const COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER = '_delete_bar_button';
|
||||
|
||||
/**
|
||||
* Button that deletes a comment.
|
||||
*/
|
||||
export class DeleteCommentBarButton extends CommentBarButton {
|
||||
/**
|
||||
* Opaque ID used to unbind event handlers during disposal.
|
||||
*/
|
||||
private readonly bindId: browserEvents.Data;
|
||||
|
||||
/**
|
||||
* SVG image displayed on this button.
|
||||
*/
|
||||
protected override readonly icon: SVGImageElement;
|
||||
|
||||
/**
|
||||
* Creates a new DeleteCommentBarButton instance.
|
||||
*
|
||||
* @param id The ID of this button's parent comment.
|
||||
* @param workspace The workspace this button's parent comment is shown on.
|
||||
* @param container An SVG group that this button should be a child of.
|
||||
*/
|
||||
constructor(
|
||||
protected readonly id: string,
|
||||
protected readonly workspace: WorkspaceSvg,
|
||||
protected readonly container: SVGGElement,
|
||||
) {
|
||||
super(id, workspace, container);
|
||||
|
||||
this.icon = dom.createSvgElement(
|
||||
Svg.IMAGE,
|
||||
{
|
||||
'class': 'blocklyDeleteIcon',
|
||||
'href': `${this.workspace.options.pathToMedia}delete-icon.svg`,
|
||||
'id': `${this.id}${COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
|
||||
},
|
||||
container,
|
||||
);
|
||||
this.bindId = browserEvents.conditionalBind(
|
||||
this.icon,
|
||||
'pointerdown',
|
||||
this,
|
||||
this.performAction.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of this button.
|
||||
*/
|
||||
dispose() {
|
||||
browserEvents.unbind(this.bindId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the positioning of this button within its container.
|
||||
*/
|
||||
override reposition() {
|
||||
const margin = this.getMargin();
|
||||
// Reset to 0 so that our position doesn't force the parent container to
|
||||
// grow.
|
||||
this.icon.setAttribute('x', `0`);
|
||||
const containerSize = this.container.getBBox();
|
||||
this.icon.setAttribute('y', `${margin}`);
|
||||
this.icon.setAttribute(
|
||||
'x',
|
||||
`${containerSize.width - this.getSize(true).getWidth()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes parent comment.
|
||||
*
|
||||
* @param e The event that triggered this action.
|
||||
*/
|
||||
override performAction(e?: Event) {
|
||||
touch.clearTouchIdentifier();
|
||||
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.getParentComment().dispose();
|
||||
e?.stopPropagation();
|
||||
getFocusManager().focusNode(this.workspace);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,12 @@ export class Connection {
|
||||
public type: number,
|
||||
) {
|
||||
this.sourceBlock_ = source;
|
||||
if (source.id.includes('_connection')) {
|
||||
throw new Error(
|
||||
`Connection ID indicator is contained in block ID. This will cause ` +
|
||||
`problems with focus: ${source.id}.`,
|
||||
);
|
||||
}
|
||||
this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,12 @@ export abstract class Field<T = any>
|
||||
throw Error('Field already bound to a block');
|
||||
}
|
||||
this.sourceBlock_ = block;
|
||||
if (block.id.includes('_field')) {
|
||||
throw new Error(
|
||||
`Field ID indicator is contained in block ID. This may cause ` +
|
||||
`problems with focus: ${block.id}.`,
|
||||
);
|
||||
}
|
||||
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
FieldValidator,
|
||||
UnattachedFieldError,
|
||||
} from './field.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import {Msg} from './msg.js';
|
||||
import * as renderManagement from './render_management.js';
|
||||
@@ -83,8 +84,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
/** Key down event data. */
|
||||
private onKeyDownWrapper: browserEvents.Data | null = null;
|
||||
|
||||
/** Key input event data. */
|
||||
private onKeyInputWrapper: browserEvents.Data | null = null;
|
||||
/** Input element input event data. */
|
||||
private onInputWrapper: browserEvents.Data | null = null;
|
||||
|
||||
/**
|
||||
* Whether the field should consider the whole parent block to be its click
|
||||
@@ -558,7 +559,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
this.onHtmlInputKeyDown_,
|
||||
);
|
||||
// Resize after every input change.
|
||||
this.onKeyInputWrapper = browserEvents.conditionalBind(
|
||||
this.onInputWrapper = browserEvents.conditionalBind(
|
||||
htmlInput,
|
||||
'input',
|
||||
this,
|
||||
@@ -572,9 +573,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
browserEvents.unbind(this.onKeyDownWrapper);
|
||||
this.onKeyDownWrapper = null;
|
||||
}
|
||||
if (this.onKeyInputWrapper) {
|
||||
browserEvents.unbind(this.onKeyInputWrapper);
|
||||
this.onKeyInputWrapper = null;
|
||||
if (this.onInputWrapper) {
|
||||
browserEvents.unbind(this.onInputWrapper);
|
||||
this.onInputWrapper = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +615,14 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
if (target instanceof FieldInput) {
|
||||
WidgetDiv.hideIfOwner(this);
|
||||
dropDownDiv.hideWithoutAnimation();
|
||||
const targetSourceBlock = target.getSourceBlock();
|
||||
if (
|
||||
target.isFullBlockField() &&
|
||||
targetSourceBlock &&
|
||||
targetSourceBlock instanceof BlockSvg
|
||||
) {
|
||||
getFocusManager().focusNode(targetSourceBlock);
|
||||
} else getFocusManager().focusNode(target);
|
||||
target.showEditor();
|
||||
}
|
||||
}
|
||||
@@ -622,7 +631,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
/**
|
||||
* Handle a change to the editor.
|
||||
*
|
||||
* @param _e Keyboard event.
|
||||
* @param _e InputEvent.
|
||||
*/
|
||||
private onHtmlInputChange(_e: Event) {
|
||||
// Intermediate value changes from user input are not confirmed until the
|
||||
|
||||
@@ -174,8 +174,15 @@ export class FocusManager {
|
||||
this.registeredTrees.push(
|
||||
new TreeRegistration(tree, rootShouldBeAutoTabbable),
|
||||
);
|
||||
const rootElement = tree.getRootFocusableNode().getFocusableElement();
|
||||
if (!rootElement.id || rootElement.id === 'null') {
|
||||
throw Error(
|
||||
`Attempting to register a tree with a root element that has an ` +
|
||||
`invalid ID: ${tree}.`,
|
||||
);
|
||||
}
|
||||
if (rootShouldBeAutoTabbable) {
|
||||
tree.getRootFocusableNode().getFocusableElement().tabIndex = 0;
|
||||
rootElement.tabIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,13 +351,22 @@ export class FocusManager {
|
||||
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
|
||||
}
|
||||
|
||||
const focusableNodeElement = focusableNode.getFocusableElement();
|
||||
if (!focusableNodeElement.id || focusableNodeElement.id === 'null') {
|
||||
// Warn that the ID is invalid, but continue execution since an invalid ID
|
||||
// will result in an unmatched (null) node. Since a request to focus
|
||||
// something was initiated, the code below will attempt to find the next
|
||||
// best thing to focus, instead.
|
||||
console.warn('Trying to focus a node that has an invalid ID.');
|
||||
}
|
||||
|
||||
// Safety check for ensuring focusNode() doesn't get called for a node that
|
||||
// isn't actually hooked up to its parent tree correctly. This usually
|
||||
// happens when calls to focusNode() interleave with asynchronous clean-up
|
||||
// operations (which can happen due to ephemeral focus and in other cases).
|
||||
// Fall back to a reasonable default since there's no valid node to focus.
|
||||
const matchedNode = FocusableTreeTraverser.findFocusableNodeFor(
|
||||
focusableNode.getFocusableElement(),
|
||||
focusableNodeElement,
|
||||
nextTree,
|
||||
);
|
||||
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);
|
||||
|
||||
@@ -8,8 +8,11 @@ import {BlockSvg} from '../block_svg.js';
|
||||
import {ConnectionType} from '../connection_type.js';
|
||||
import type {Field} from '../field.js';
|
||||
import type {Icon} from '../icons/icon.js';
|
||||
import type {IBoundedElement} from '../interfaces/i_bounded_element.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import type {ISelectable} from '../interfaces/i_selectable.js';
|
||||
import {RenderedConnection} from '../rendered_connection.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
@@ -143,21 +146,25 @@ function getBlockNavigationCandidates(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next/previous stack relative to the given block's stack.
|
||||
* Returns the next/previous stack relative to the given element's stack.
|
||||
*
|
||||
* @param current The block whose stack will be navigated relative to.
|
||||
* @param current The element whose stack will be navigated relative to.
|
||||
* @param delta The difference in index to navigate; positive values navigate
|
||||
* to the nth next stack, while negative values navigate to the nth previous
|
||||
* stack.
|
||||
* @returns The first block in the stack offset by `delta` relative to the
|
||||
* current block's stack, or the last block in the stack offset by `delta`
|
||||
* relative to the current block's stack when navigating backwards.
|
||||
* @returns The first element in the stack offset by `delta` relative to the
|
||||
* current element's stack, or the last element in the stack offset by
|
||||
* `delta` relative to the current element's stack when navigating backwards.
|
||||
*/
|
||||
export function navigateStacks(current: BlockSvg, delta: number) {
|
||||
const stacks = current.workspace.getTopBlocks(true);
|
||||
const currentIndex = stacks.indexOf(current.getRootBlock());
|
||||
export function navigateStacks(current: ISelectable, delta: number) {
|
||||
const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg)
|
||||
.getTopBoundedElements(true)
|
||||
.filter((element: IBoundedElement) => isFocusableNode(element));
|
||||
const currentIndex = stacks.indexOf(
|
||||
current instanceof BlockSvg ? current.getRootBlock() : current,
|
||||
);
|
||||
const targetIndex = currentIndex + delta;
|
||||
let result: BlockSvg | null = null;
|
||||
let result: IFocusableNode | null = null;
|
||||
if (targetIndex >= 0 && targetIndex < stacks.length) {
|
||||
result = stacks[targetIndex];
|
||||
} else if (targetIndex < 0) {
|
||||
@@ -166,9 +173,9 @@ export function navigateStacks(current: BlockSvg, delta: number) {
|
||||
result = stacks[0];
|
||||
}
|
||||
|
||||
// When navigating to a previous stack, our previous sibling is the last
|
||||
// When navigating to a previous block stack, our previous sibling is the last
|
||||
// block in it.
|
||||
if (delta < 0 && result) {
|
||||
if (delta < 0 && result instanceof BlockSvg) {
|
||||
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
|
||||
}
|
||||
|
||||
|
||||
86
core/keyboard_nav/comment_bar_button_navigation_policy.ts
Normal file
86
core/keyboard_nav/comment_bar_button_navigation_policy.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {CommentBarButton} from '../comments/comment_bar_button.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a CommentBarButton.
|
||||
*/
|
||||
export class CommentBarButtonNavigationPolicy
|
||||
implements INavigationPolicy<CommentBarButton>
|
||||
{
|
||||
/**
|
||||
* Returns the first child of the given CommentBarButton.
|
||||
*
|
||||
* @param _current The CommentBarButton to return the first child of.
|
||||
* @returns Null.
|
||||
*/
|
||||
getFirstChild(_current: CommentBarButton): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given CommentBarButton.
|
||||
*
|
||||
* @param current The CommentBarButton to return the parent of.
|
||||
* @returns The parent comment of the given CommentBarButton.
|
||||
*/
|
||||
getParent(current: CommentBarButton): IFocusableNode | null {
|
||||
return current.getParentComment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given CommentBarButton.
|
||||
*
|
||||
* @param current The CommentBarButton to find the following element of.
|
||||
* @returns The next CommentBarButton, if any.
|
||||
*/
|
||||
getNextSibling(current: CommentBarButton): IFocusableNode | null {
|
||||
const children = current.getParentComment().view.getCommentBarButtons();
|
||||
const currentIndex = children.indexOf(current);
|
||||
if (currentIndex >= 0 && currentIndex + 1 < children.length) {
|
||||
return children[currentIndex + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given CommentBarButton.
|
||||
*
|
||||
* @param current The CommentBarButton to find the preceding element of.
|
||||
* @returns The CommentBarButton's previous CommentBarButton, if any.
|
||||
*/
|
||||
getPreviousSibling(current: CommentBarButton): IFocusableNode | null {
|
||||
const children = current.getParentComment().view.getCommentBarButtons();
|
||||
const currentIndex = children.indexOf(current);
|
||||
if (currentIndex > 0) {
|
||||
return children[currentIndex - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given CommentBarButton can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given CommentBarButton can be focused.
|
||||
*/
|
||||
isNavigable(current: CommentBarButton): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is an CommentBarButton.
|
||||
*/
|
||||
isApplicable(current: any): current is CommentBarButton {
|
||||
return current instanceof CommentBarButton;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -38,11 +39,11 @@ export class LineCursor extends Marker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the cursor to the next previous connection, next connection or block
|
||||
* in the pre order traversal. Finds the next node in the pre order traversal.
|
||||
* Moves the cursor to the next block or workspace comment in the pre-order
|
||||
* traversal.
|
||||
*
|
||||
* @returns The next node, or null if the current node is
|
||||
* not set or there is no next value.
|
||||
* @returns The next node, or null if the current node is not set or there is
|
||||
* no next value.
|
||||
*/
|
||||
next(): IFocusableNode | null {
|
||||
const curNode = this.getCurNode();
|
||||
@@ -53,8 +54,9 @@ export class LineCursor extends Marker {
|
||||
curNode,
|
||||
(candidate: IFocusableNode | null) => {
|
||||
return (
|
||||
candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()
|
||||
(candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()) ||
|
||||
candidate instanceof RenderedWorkspaceComment
|
||||
);
|
||||
},
|
||||
true,
|
||||
@@ -87,11 +89,11 @@ export class LineCursor extends Marker {
|
||||
return newNode;
|
||||
}
|
||||
/**
|
||||
* Moves the cursor to the previous next connection or previous connection in
|
||||
* the pre order traversal.
|
||||
* Moves the cursor to the previous block or workspace comment in the
|
||||
* pre-order traversal.
|
||||
*
|
||||
* @returns The previous node, or null if the current node
|
||||
* is not set or there is no previous value.
|
||||
* @returns The previous node, or null if the current node is not set or there
|
||||
* is no previous value.
|
||||
*/
|
||||
prev(): IFocusableNode | null {
|
||||
const curNode = this.getCurNode();
|
||||
@@ -102,8 +104,9 @@ export class LineCursor extends Marker {
|
||||
curNode,
|
||||
(candidate: IFocusableNode | null) => {
|
||||
return (
|
||||
candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()
|
||||
(candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()) ||
|
||||
candidate instanceof RenderedWorkspaceComment
|
||||
);
|
||||
},
|
||||
true,
|
||||
@@ -398,6 +401,8 @@ export class LineCursor extends Marker {
|
||||
block.workspace.scrollBoundsIntoView(
|
||||
block.getBoundingRectangleWithoutChildren(),
|
||||
);
|
||||
} else if (newNode instanceof RenderedWorkspaceComment) {
|
||||
newNode.workspace.scrollBoundsIntoView(newNode.getBoundingRectangle());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
77
core/keyboard_nav/workspace_comment_navigation_policy.ts
Normal file
77
core/keyboard_nav/workspace_comment_navigation_policy.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import {navigateStacks} from './block_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from an RenderedWorkspaceComment.
|
||||
*/
|
||||
export class WorkspaceCommentNavigationPolicy
|
||||
implements INavigationPolicy<RenderedWorkspaceComment>
|
||||
{
|
||||
/**
|
||||
* Returns the first child of the given workspace comment.
|
||||
*
|
||||
* @param current The workspace comment to return the first child of.
|
||||
* @returns The first child button of the given comment.
|
||||
*/
|
||||
getFirstChild(current: RenderedWorkspaceComment): IFocusableNode | null {
|
||||
return current.view.getCommentBarButtons()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given workspace comment.
|
||||
*
|
||||
* @param current The workspace comment to return the parent of.
|
||||
* @returns The parent workspace of the given comment.
|
||||
*/
|
||||
getParent(current: RenderedWorkspaceComment): IFocusableNode | null {
|
||||
return current.workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given workspace comment.
|
||||
*
|
||||
* @param current The workspace comment to find the following element of.
|
||||
* @returns The next workspace comment or block stack, if any.
|
||||
*/
|
||||
getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null {
|
||||
return navigateStacks(current, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given workspace comment.
|
||||
*
|
||||
* @param current The workspace comment to find the preceding element of.
|
||||
* @returns The previous workspace comment or block stack, if any.
|
||||
*/
|
||||
getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null {
|
||||
return navigateStacks(current, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given workspace comment can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given workspace comment can be focused.
|
||||
*/
|
||||
isNavigable(current: RenderedWorkspaceComment): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is an RenderedWorkspaceComment.
|
||||
*/
|
||||
isApplicable(current: any): current is RenderedWorkspaceComment {
|
||||
return current instanceof RenderedWorkspaceComment;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
// Former goog.module ID: Blockly.MarkerManager
|
||||
|
||||
import type {LineCursor} from './keyboard_nav/line_cursor.js';
|
||||
import {LineCursor} from './keyboard_nav/line_cursor.js';
|
||||
import type {Marker} from './keyboard_nav/marker.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
@@ -23,7 +23,7 @@ export class MarkerManager {
|
||||
static readonly LOCAL_MARKER = 'local_marker_1';
|
||||
|
||||
/** The cursor. */
|
||||
private cursor: LineCursor | null = null;
|
||||
private cursor: LineCursor;
|
||||
|
||||
/** The map of markers for the workspace. */
|
||||
private markers = new Map<string, Marker>();
|
||||
@@ -32,7 +32,9 @@ export class MarkerManager {
|
||||
* @param workspace The workspace for the marker manager.
|
||||
* @internal
|
||||
*/
|
||||
constructor(private readonly workspace: WorkspaceSvg) {}
|
||||
constructor(private readonly workspace: WorkspaceSvg) {
|
||||
this.cursor = new LineCursor(this.workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the marker by adding it to the map of markers.
|
||||
@@ -72,7 +74,7 @@ export class MarkerManager {
|
||||
*
|
||||
* @returns The cursor for this workspace.
|
||||
*/
|
||||
getCursor(): LineCursor | null {
|
||||
getCursor(): LineCursor {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
@@ -109,9 +111,6 @@ export class MarkerManager {
|
||||
this.unregisterMarker(markerId);
|
||||
}
|
||||
this.markers.clear();
|
||||
if (this.cursor) {
|
||||
this.cursor.dispose();
|
||||
this.cursor = null;
|
||||
}
|
||||
this.cursor.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
|
||||
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
|
||||
import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js';
|
||||
import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js';
|
||||
import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js';
|
||||
import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js';
|
||||
import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js';
|
||||
import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js';
|
||||
|
||||
type RuleList<T> = INavigationPolicy<T>[];
|
||||
@@ -29,6 +31,8 @@ export class Navigator {
|
||||
new ConnectionNavigationPolicy(),
|
||||
new WorkspaceNavigationPolicy(),
|
||||
new IconNavigationPolicy(),
|
||||
new WorkspaceCommentNavigationPolicy(),
|
||||
new CommentBarButtonNavigationPolicy(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,8 +11,7 @@ import * as clipboard from './clipboard.js';
|
||||
import {RenderedWorkspaceComment} from './comments.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import {Gesture} from './gesture.js';
|
||||
import {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.js';
|
||||
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
|
||||
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
|
||||
import {isDraggable} from './interfaces/i_draggable.js';
|
||||
import {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
@@ -67,7 +66,7 @@ export function registerDelete() {
|
||||
focused != null &&
|
||||
isIDeletable(focused) &&
|
||||
focused.isDeletable() &&
|
||||
!Gesture.inProgress() &&
|
||||
!workspace.isDragging() &&
|
||||
// Don't delete the block if a field editor is open
|
||||
!getFocusManager().ephemeralFocusTaken()
|
||||
);
|
||||
@@ -93,9 +92,6 @@ export function registerDelete() {
|
||||
ShortcutRegistry.registry.register(deleteShortcut);
|
||||
}
|
||||
|
||||
let copyData: ICopyData | null = null;
|
||||
let copyCoords: Coordinate | null = null;
|
||||
|
||||
/**
|
||||
* Determine if a focusable node can be copied.
|
||||
*
|
||||
@@ -176,12 +172,12 @@ export function registerCopy() {
|
||||
if (!focused.workspace.isFlyout) {
|
||||
targetWorkspace.hideChaff();
|
||||
}
|
||||
copyData = focused.toCopyData();
|
||||
copyCoords =
|
||||
|
||||
const copyCoords =
|
||||
isDraggable(focused) && focused.workspace == targetWorkspace
|
||||
? focused.getRelativeToSurfaceXY()
|
||||
: null;
|
||||
return !!copyData;
|
||||
: undefined;
|
||||
return !!clipboard.copy(focused, copyCoords);
|
||||
},
|
||||
keyCodes: [ctrlC, metaC],
|
||||
};
|
||||
@@ -216,10 +212,10 @@ export function registerCut() {
|
||||
if (!focused || !isCuttable(focused) || !isICopyable(focused)) {
|
||||
return false;
|
||||
}
|
||||
copyData = focused.toCopyData();
|
||||
copyCoords = isDraggable(focused)
|
||||
const copyCoords = isDraggable(focused)
|
||||
? focused.getRelativeToSurfaceXY()
|
||||
: null;
|
||||
: undefined;
|
||||
const copyData = clipboard.copy(focused, copyCoords);
|
||||
|
||||
if (focused instanceof BlockSvg) {
|
||||
focused.checkAndDelete();
|
||||
@@ -247,12 +243,19 @@ export function registerPaste() {
|
||||
|
||||
const pasteShortcut: KeyboardShortcut = {
|
||||
name: names.PASTE,
|
||||
preconditionFn(workspace) {
|
||||
preconditionFn() {
|
||||
// Regardless of the currently focused workspace, we will only
|
||||
// paste into the last-copied-from workspace.
|
||||
const workspace = clipboard.getLastCopiedWorkspace();
|
||||
// If we don't know where we copied from, we don't know where to paste.
|
||||
// If the workspace isn't rendered (e.g. closed mutator workspace),
|
||||
// we can't paste into it.
|
||||
if (!workspace || !workspace.rendered) return false;
|
||||
const targetWorkspace = workspace.isFlyout
|
||||
? workspace.targetWorkspace
|
||||
: workspace;
|
||||
return (
|
||||
!!copyData &&
|
||||
!!clipboard.getLastCopiedData() &&
|
||||
!!targetWorkspace &&
|
||||
!targetWorkspace.isReadOnly() &&
|
||||
!targetWorkspace.isDragging() &&
|
||||
@@ -260,10 +263,15 @@ export function registerPaste() {
|
||||
);
|
||||
},
|
||||
callback(workspace: WorkspaceSvg, e: Event) {
|
||||
const copyData = clipboard.getLastCopiedData();
|
||||
if (!copyData) return false;
|
||||
const targetWorkspace = workspace.isFlyout
|
||||
? workspace.targetWorkspace
|
||||
: workspace;
|
||||
|
||||
const copyWorkspace = clipboard.getLastCopiedWorkspace();
|
||||
if (!copyWorkspace) return false;
|
||||
|
||||
const targetWorkspace = copyWorkspace.isFlyout
|
||||
? copyWorkspace.targetWorkspace
|
||||
: copyWorkspace;
|
||||
if (!targetWorkspace || targetWorkspace.isReadOnly()) return false;
|
||||
|
||||
if (e instanceof PointerEvent) {
|
||||
@@ -279,6 +287,7 @@ export function registerPaste() {
|
||||
return !!clipboard.paste(copyData, targetWorkspace, mouseCoords);
|
||||
}
|
||||
|
||||
const copyCoords = clipboard.getLastCopiedLocation();
|
||||
if (!copyCoords) {
|
||||
// If we don't have location data about the original copyable, let the
|
||||
// paster determine position.
|
||||
@@ -322,7 +331,7 @@ export function registerUndo() {
|
||||
preconditionFn(workspace) {
|
||||
return (
|
||||
!workspace.isReadOnly() &&
|
||||
!Gesture.inProgress() &&
|
||||
!workspace.isDragging() &&
|
||||
!getFocusManager().ephemeralFocusTaken()
|
||||
);
|
||||
},
|
||||
@@ -360,7 +369,7 @@ export function registerRedo() {
|
||||
name: names.REDO,
|
||||
preconditionFn(workspace) {
|
||||
return (
|
||||
!Gesture.inProgress() &&
|
||||
!workspace.isDragging() &&
|
||||
!workspace.isReadOnly() &&
|
||||
!getFocusManager().ephemeralFocusTaken()
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {KeyboardShortcut} from '../shortcut_registry.js';
|
||||
import * as Touch from '../touch.js';
|
||||
import * as aria from '../utils/aria.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import * as idGenerator from '../utils/idgenerator.js';
|
||||
import {Rect} from '../utils/rect.js';
|
||||
import * as toolbox from '../utils/toolbox.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
@@ -185,6 +186,7 @@ export class Toolbox
|
||||
const svg = workspace.getParentSvg();
|
||||
|
||||
const container = this.createContainer_();
|
||||
container.id = idGenerator.getNextUniqueId();
|
||||
|
||||
this.contentsDiv_ = this.createContentsContainer_();
|
||||
aria.setRole(this.contentsDiv_, aria.Role.TREE);
|
||||
@@ -1170,6 +1172,7 @@ Css.register(`
|
||||
|
||||
/* Category tree in Toolbox. */
|
||||
.blocklyToolbox {
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
@@ -79,8 +79,8 @@ export class FocusableTreeTraverser {
|
||||
* traversed but its nodes will never be returned here per the contract of
|
||||
* IFocusableTree.lookUpFocusableNode.
|
||||
*
|
||||
* The provided element must have a non-null ID that conforms to the contract
|
||||
* mentioned in IFocusableNode.
|
||||
* The provided element must have a non-null, non-empty ID that conforms to
|
||||
* the contract mentioned in IFocusableNode.
|
||||
*
|
||||
* @param element The HTML or SVG element being sought.
|
||||
* @param tree The tree under which the provided element may be a descendant.
|
||||
@@ -90,6 +90,10 @@ export class FocusableTreeTraverser {
|
||||
element: HTMLElement | SVGElement,
|
||||
tree: IFocusableTree,
|
||||
): IFocusableNode | null {
|
||||
// Note that the null check is due to Element.setAttribute() converting null
|
||||
// to a string.
|
||||
if (!element.id || element.id === 'null') return null;
|
||||
|
||||
// First, match against subtrees.
|
||||
const subTreeMatches = tree.getNestedTrees().map((tree) => {
|
||||
return FocusableTreeTraverser.findFocusableNodeFor(element, tree);
|
||||
|
||||
@@ -32,6 +32,16 @@ export class Rect {
|
||||
public right: number,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Converts a DOM or SVG Rect to a Blockly Rect.
|
||||
*
|
||||
* @param rect The rectangle to convert.
|
||||
* @returns A representation of the same rectangle as a Blockly Rect.
|
||||
*/
|
||||
static from(rect: DOMRect | SVGRect): Rect {
|
||||
return new Rect(rect.y, rect.y + rect.height, rect.x, rect.x + rect.width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new copy of this rectangle.
|
||||
*
|
||||
@@ -51,6 +61,11 @@ export class Rect {
|
||||
return this.right - this.left;
|
||||
}
|
||||
|
||||
/** Returns the top left coordinate of this rectangle. */
|
||||
getOrigin(): Coordinate {
|
||||
return new Coordinate(this.left, this.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether this rectangle contains a x/y coordinate.
|
||||
*
|
||||
|
||||
@@ -21,6 +21,7 @@ import * as common from './common.js';
|
||||
import type {ConnectionDB} from './connection_db.js';
|
||||
import type {Abstract} from './events/events_abstract.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
import type {IConnectionChecker} from './interfaces/i_connection_checker.js';
|
||||
import {IProcedureMap} from './interfaces/i_procedure_map.js';
|
||||
import type {IVariableMap} from './interfaces/i_variable_map.js';
|
||||
@@ -35,6 +36,7 @@ import * as arrayUtils from './utils/array.js';
|
||||
import * as deprecation from './utils/deprecation.js';
|
||||
import * as idGenerator from './utils/idgenerator.js';
|
||||
import * as math from './utils/math.js';
|
||||
import {Rect} from './utils/rect.js';
|
||||
import type * as toolbox from './utils/toolbox.js';
|
||||
import {deleteVariable, getVariableUsesById} from './variables.js';
|
||||
|
||||
@@ -181,10 +183,31 @@ export class Workspace {
|
||||
a: Block | WorkspaceComment,
|
||||
b: Block | WorkspaceComment,
|
||||
): number {
|
||||
const wrap = (element: Block | WorkspaceComment) => {
|
||||
return {
|
||||
getBoundingRectangle: () => {
|
||||
const xy = element.getRelativeToSurfaceXY();
|
||||
return new Rect(xy.y, xy.y, xy.x, xy.x);
|
||||
},
|
||||
moveBy: () => {},
|
||||
};
|
||||
};
|
||||
return this.sortByOrigin(wrap(a), wrap(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts bounded elements on the workspace by their relative position, top to
|
||||
* bottom (with slight LTR or RTL bias).
|
||||
*
|
||||
* @param a The first element to sort.
|
||||
* @param b The second elment to sort.
|
||||
* @returns -1, 0 or 1 depending on the sort order.
|
||||
*/
|
||||
protected sortByOrigin(a: IBoundedElement, b: IBoundedElement): number {
|
||||
const offset =
|
||||
Math.sin(math.toRadians(Workspace.SCAN_ANGLE)) * (this.RTL ? -1 : 1);
|
||||
const aXY = a.getRelativeToSurfaceXY();
|
||||
const bXY = b.getRelativeToSurfaceXY();
|
||||
const aXY = a.getBoundingRectangle().getOrigin();
|
||||
const bXY = b.getBoundingRectangle().getOrigin();
|
||||
return aXY.y + offset * aXY.x - (bXY.y + offset * bXY.x);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ import type {Block} from './block.js';
|
||||
import type {BlockSvg} from './block_svg.js';
|
||||
import type {BlocklyOptions} from './blockly_options.js';
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js';
|
||||
import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js';
|
||||
import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js';
|
||||
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
|
||||
import {WorkspaceComment} from './comments/workspace_comment.js';
|
||||
import * as common from './common.js';
|
||||
@@ -478,10 +480,7 @@ export class WorkspaceSvg
|
||||
* @internal
|
||||
*/
|
||||
getMarker(id: string): Marker | null {
|
||||
if (this.markerManager) {
|
||||
return this.markerManager.getMarker(id);
|
||||
}
|
||||
return null;
|
||||
return this.markerManager.getMarker(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,11 +488,8 @@ export class WorkspaceSvg
|
||||
*
|
||||
* @returns The cursor for the workspace.
|
||||
*/
|
||||
getCursor(): LineCursor | null {
|
||||
if (this.markerManager) {
|
||||
return this.markerManager.getCursor();
|
||||
}
|
||||
return null;
|
||||
getCursor(): LineCursor {
|
||||
return this.markerManager.getCursor();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -897,10 +893,7 @@ export class WorkspaceSvg
|
||||
}
|
||||
|
||||
this.renderer.dispose();
|
||||
|
||||
if (this.markerManager) {
|
||||
this.markerManager.dispose();
|
||||
}
|
||||
this.markerManager.dispose();
|
||||
|
||||
super.dispose();
|
||||
|
||||
@@ -2266,8 +2259,8 @@ export class WorkspaceSvg
|
||||
*
|
||||
* @param comment comment to add.
|
||||
*/
|
||||
override addTopComment(comment: WorkspaceComment) {
|
||||
this.addTopBoundedElement(comment as RenderedWorkspaceComment);
|
||||
override addTopComment(comment: RenderedWorkspaceComment) {
|
||||
this.addTopBoundedElement(comment);
|
||||
super.addTopComment(comment);
|
||||
}
|
||||
|
||||
@@ -2276,11 +2269,31 @@ export class WorkspaceSvg
|
||||
*
|
||||
* @param comment comment to remove.
|
||||
*/
|
||||
override removeTopComment(comment: WorkspaceComment) {
|
||||
this.removeTopBoundedElement(comment as RenderedWorkspaceComment);
|
||||
override removeTopComment(comment: RenderedWorkspaceComment) {
|
||||
this.removeTopBoundedElement(comment);
|
||||
super.removeTopComment(comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of comments on this workspace.
|
||||
*
|
||||
* @param ordered If true, sorts the comments based on their position.
|
||||
* @returns A list of workspace comments.
|
||||
*/
|
||||
override getTopComments(ordered = false): RenderedWorkspaceComment[] {
|
||||
return super.getTopComments(ordered) as RenderedWorkspaceComment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the workspace comment with the given ID, if any.
|
||||
*
|
||||
* @param id The ID of the comment to retrieve.
|
||||
* @returns The workspace comment with the given ID, or null.
|
||||
*/
|
||||
override getCommentById(id: string): RenderedWorkspaceComment | null {
|
||||
return super.getCommentById(id) as RenderedWorkspaceComment | null;
|
||||
}
|
||||
|
||||
override getRootWorkspace(): WorkspaceSvg | null {
|
||||
return super.getRootWorkspace() as WorkspaceSvg | null;
|
||||
}
|
||||
@@ -2308,8 +2321,15 @@ export class WorkspaceSvg
|
||||
*
|
||||
* @returns The top-level bounded elements.
|
||||
*/
|
||||
getTopBoundedElements(): IBoundedElement[] {
|
||||
return new Array<IBoundedElement>().concat(this.topBoundedElements);
|
||||
getTopBoundedElements(ordered = false): IBoundedElement[] {
|
||||
const elements = new Array<IBoundedElement>().concat(
|
||||
this.topBoundedElements,
|
||||
);
|
||||
if (ordered) {
|
||||
elements.sort(this.sortByOrigin.bind(this));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2794,19 +2814,32 @@ export class WorkspaceSvg
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search for a specific workspace comment editor
|
||||
// (only if id seems like it is one).
|
||||
const commentEditorIndicator = id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER);
|
||||
if (commentEditorIndicator !== -1) {
|
||||
const commentId = id.substring(0, commentEditorIndicator);
|
||||
// Search for a specific workspace comment or comment icon if the ID
|
||||
// indicates the presence of one.
|
||||
const commentIdSeparatorIndex = Math.max(
|
||||
id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER),
|
||||
id.indexOf(COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER),
|
||||
id.indexOf(COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER),
|
||||
);
|
||||
if (commentIdSeparatorIndex !== -1) {
|
||||
const commentId = id.substring(0, commentIdSeparatorIndex);
|
||||
const comment = this.searchForWorkspaceComment(commentId);
|
||||
if (comment) {
|
||||
return comment.getEditorFocusableNode();
|
||||
if (id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER) > -1) {
|
||||
return comment.getEditorFocusableNode();
|
||||
} else {
|
||||
return (
|
||||
comment.view
|
||||
.getCommentBarButtons()
|
||||
.find((button) => button.getFocusableElement().id.includes(id)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search for a specific block.
|
||||
// Don't use `getBlockById` because the block ID is not guaranteeed
|
||||
// Don't use `getBlockById` because the block ID is not guaranteed
|
||||
// to be globally unique, but the ID on the focusable element is.
|
||||
const block = this.getAllBlocks(false).find(
|
||||
(block) => block.getFocusableElement().id === id,
|
||||
|
||||
@@ -184,7 +184,7 @@ export default [
|
||||
files: [
|
||||
'eslint.config.mjs',
|
||||
'.prettierrc.js',
|
||||
'gulpfile.js',
|
||||
'gulpfile.mjs',
|
||||
'scripts/helpers.js',
|
||||
'tests/mocha/.mocharc.js',
|
||||
'tests/migration/validate-renamings.mjs',
|
||||
|
||||
54
gulpfile.js
54
gulpfile.js
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Gulp script to build Blockly for Node & NPM.
|
||||
* Run this script by calling "npm install" in this directory.
|
||||
*/
|
||||
/* eslint-env node */
|
||||
|
||||
const gulp = require('gulp');
|
||||
|
||||
const buildTasks = require('./scripts/gulpfiles/build_tasks');
|
||||
const packageTasks = require('./scripts/gulpfiles/package_tasks');
|
||||
const gitTasks = require('./scripts/gulpfiles/git_tasks');
|
||||
const appengineTasks = require('./scripts/gulpfiles/appengine_tasks');
|
||||
const releaseTasks = require('./scripts/gulpfiles/release_tasks');
|
||||
const docsTasks = require('./scripts/gulpfiles/docs_tasks');
|
||||
const testTasks = require('./scripts/gulpfiles/test_tasks');
|
||||
|
||||
module.exports = {
|
||||
// Default target if gulp invoked without specifying.
|
||||
default: buildTasks.build,
|
||||
|
||||
// Main sequence targets. They already invoke prerequisites.
|
||||
langfiles: buildTasks.langfiles, // Build build/msg/*.js from msg/json/*.
|
||||
tsc: buildTasks.tsc,
|
||||
deps: buildTasks.deps,
|
||||
minify: buildTasks.minify,
|
||||
build: buildTasks.build,
|
||||
package: packageTasks.package,
|
||||
publish: releaseTasks.publish,
|
||||
publishBeta: releaseTasks.publishBeta,
|
||||
prepareDemos: appengineTasks.prepareDemos,
|
||||
deployDemos: appengineTasks.deployDemos,
|
||||
deployDemosBeta: appengineTasks.deployDemosBeta,
|
||||
gitUpdateGithubPages: gitTasks.updateGithubPages,
|
||||
|
||||
// Manually-invokable targets, with prerequisites where required.
|
||||
messages: buildTasks.messages, // Generate msg/json/en.json et al.
|
||||
clean: gulp.parallel(buildTasks.cleanBuildDir, packageTasks.cleanReleaseDir),
|
||||
test: testTasks.test,
|
||||
testGenerators: testTasks.generators,
|
||||
buildAdvancedCompilationTest: buildTasks.buildAdvancedCompilationTest,
|
||||
gitCreateRC: gitTasks.createRC,
|
||||
docs: docsTasks.docs,
|
||||
|
||||
// Legacy targets, to be deleted.
|
||||
recompile: releaseTasks.recompile,
|
||||
gitSyncDevelop: gitTasks.syncDevelop,
|
||||
gitSyncMaster: gitTasks.syncMaster,
|
||||
};
|
||||
95
gulpfile.mjs
Normal file
95
gulpfile.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Gulp script to build Blockly for Node & NPM.
|
||||
* Run this script by calling "npm install" in this directory.
|
||||
*/
|
||||
/* eslint-env node */
|
||||
|
||||
// Needed to prevent prettier from munging exports order, due to
|
||||
// https://github.com/simonhaenisch/prettier-plugin-organize-imports/issues/146
|
||||
// - but has the unfortunate side effect of suppressing ordering of
|
||||
// imports too:
|
||||
//
|
||||
// organize-imports-ignore
|
||||
|
||||
import {parallel} from 'gulp';
|
||||
import {
|
||||
deployDemos,
|
||||
deployDemosBeta,
|
||||
prepareDemos,
|
||||
} from './scripts/gulpfiles/appengine_tasks.mjs';
|
||||
import {
|
||||
build,
|
||||
buildAdvancedCompilationTest,
|
||||
cleanBuildDir,
|
||||
langfiles,
|
||||
messages,
|
||||
minify,
|
||||
tsc,
|
||||
} from './scripts/gulpfiles/build_tasks.mjs';
|
||||
import {docs} from './scripts/gulpfiles/docs_tasks.mjs';
|
||||
import {
|
||||
createRC,
|
||||
syncDevelop,
|
||||
syncMaster,
|
||||
updateGithubPages,
|
||||
} from './scripts/gulpfiles/git_tasks.mjs';
|
||||
import {cleanReleaseDir, pack} from './scripts/gulpfiles/package_tasks.mjs';
|
||||
import {
|
||||
publish,
|
||||
publishBeta,
|
||||
recompile,
|
||||
} from './scripts/gulpfiles/release_tasks.mjs';
|
||||
import {generators, test} from './scripts/gulpfiles/test_tasks.mjs';
|
||||
|
||||
const clean = parallel(cleanBuildDir, cleanReleaseDir);
|
||||
|
||||
// Default target if gulp invoked without specifying.
|
||||
export default build;
|
||||
|
||||
// Main sequence targets. They already invoke prerequisites. Listed
|
||||
// in typical order of invocation, and strictly listing prerequisites
|
||||
// before dependants.
|
||||
//
|
||||
// prettier-ignore
|
||||
export {
|
||||
langfiles,
|
||||
tsc,
|
||||
minify,
|
||||
build,
|
||||
pack, // Formerly package.
|
||||
publishBeta,
|
||||
publish,
|
||||
prepareDemos,
|
||||
deployDemosBeta,
|
||||
deployDemos,
|
||||
updateGithubPages as gitUpdateGithubPages,
|
||||
}
|
||||
|
||||
// Manually-invokable targets that also invoke prerequisites where
|
||||
// required.
|
||||
//
|
||||
// prettier-ignore
|
||||
export {
|
||||
messages, // Generate msg/json/en.json et al.
|
||||
clean,
|
||||
test,
|
||||
generators as testGenerators,
|
||||
buildAdvancedCompilationTest,
|
||||
createRC as gitCreateRC,
|
||||
docs,
|
||||
}
|
||||
|
||||
// Legacy targets, to be deleted.
|
||||
//
|
||||
// prettier-ignore
|
||||
export {
|
||||
recompile,
|
||||
syncDevelop as gitSyncDevelop,
|
||||
syncMaster as gitSyncMaster,
|
||||
}
|
||||
1372
package-lock.json
generated
1372
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@
|
||||
"lint:fix": "eslint . --fix",
|
||||
"langfiles": "gulp langfiles",
|
||||
"minify": "gulp minify",
|
||||
"package": "gulp package",
|
||||
"package": "gulp pack",
|
||||
"postinstall": "patch-package",
|
||||
"prepareDemos": "gulp prepareDemos",
|
||||
"publish": "npm ci && gulp publish",
|
||||
@@ -100,7 +100,7 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@blockly/block-test": "^6.0.4",
|
||||
"@blockly/block-test": "^7.0.1",
|
||||
"@blockly/dev-tools": "^9.0.0",
|
||||
"@blockly/theme-modern": "^6.0.3",
|
||||
"@hyperjump/browser": "^1.1.4",
|
||||
@@ -113,11 +113,11 @@
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-plugin-jsdoc": "^50.5.0",
|
||||
"eslint-plugin-jsdoc": "^51.3.1",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"glob": "^11.0.1",
|
||||
"globals": "^16.0.0",
|
||||
"google-closure-compiler": "^20240317.0.0",
|
||||
"google-closure-compiler": "^20250625.0.0",
|
||||
"gulp": "^5.0.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-gzip": "^1.4.2",
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
* @fileoverview Gulp script to deploy Blockly demos on appengine.
|
||||
*/
|
||||
|
||||
const gulp = require('gulp');
|
||||
import * as gulp from 'gulp';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const execSync = require('child_process').execSync;
|
||||
const buildTasks = require('./build_tasks.js');
|
||||
const packageTasks = require('./package_tasks.js');
|
||||
const {rimraf} = require('rimraf');
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {execSync} from 'child_process';
|
||||
import * as buildTasks from './build_tasks.mjs';
|
||||
import {getPackageJson} from './helper_tasks.mjs';
|
||||
import * as packageTasks from './package_tasks.mjs';
|
||||
import {rimraf} from 'rimraf';
|
||||
|
||||
const packageJson = require('../../package.json');
|
||||
const demoTmpDir = '../_deploy';
|
||||
const demoStaticTmpDir = '../_deploy/static';
|
||||
|
||||
@@ -123,7 +123,7 @@ function deployToAndClean(demoVersion) {
|
||||
*/
|
||||
function getDemosVersion() {
|
||||
// Replace all '.' with '-' e.g. 9-3-3-beta-2
|
||||
return packageJson.version.replace(/\./g, '-');
|
||||
return getPackageJson().version.replace(/\./g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +162,7 @@ function deployBetaAndClean(done) {
|
||||
*
|
||||
* Prerequisites (invoked): clean, build
|
||||
*/
|
||||
const prepareDemos = gulp.series(
|
||||
export const prepareDemos = gulp.series(
|
||||
prepareDeployDir,
|
||||
gulp.parallel(
|
||||
gulp.series(
|
||||
@@ -180,16 +180,9 @@ const prepareDemos = gulp.series(
|
||||
/**
|
||||
* Deploys demos.
|
||||
*/
|
||||
const deployDemos = gulp.series(prepareDemos, deployAndClean);
|
||||
export const deployDemos = gulp.series(prepareDemos, deployAndClean);
|
||||
|
||||
/**
|
||||
* Deploys beta version of demos (version appended with -beta).
|
||||
*/
|
||||
const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean);
|
||||
|
||||
module.exports = {
|
||||
// Main sequence targets. Each should invoke any immediate prerequisite(s).
|
||||
deployDemos: deployDemos,
|
||||
deployDemosBeta: deployDemosBeta,
|
||||
prepareDemos: prepareDemos
|
||||
};
|
||||
export const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean);
|
||||
@@ -8,25 +8,28 @@
|
||||
* @fileoverview Gulp script to build Blockly for Node & NPM.
|
||||
*/
|
||||
|
||||
const gulp = require('gulp');
|
||||
gulp.replace = require('gulp-replace');
|
||||
gulp.rename = require('gulp-rename');
|
||||
gulp.sourcemaps = require('gulp-sourcemaps');
|
||||
import * as gulp from 'gulp';
|
||||
import replace from 'gulp-replace';
|
||||
import rename from 'gulp-rename';
|
||||
import sourcemaps from 'gulp-sourcemaps';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsPromises = require('fs/promises');
|
||||
const {exec, execSync} = require('child_process');
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import {exec, execSync} from 'child_process';
|
||||
|
||||
const {globSync} = require('glob');
|
||||
const closureCompiler = require('google-closure-compiler').gulp();
|
||||
const argv = require('yargs').argv;
|
||||
const {rimraf} = require('rimraf');
|
||||
import {globSync} from 'glob';
|
||||
import {gulp as closureCompiler} from 'google-closure-compiler';
|
||||
import yargs from 'yargs';
|
||||
import {hideBin} from 'yargs/helpers';
|
||||
import {rimraf} from 'rimraf';
|
||||
|
||||
const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} = require('./config');
|
||||
const {getPackageJson} = require('./helper_tasks');
|
||||
import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} from './config.mjs';
|
||||
import {getPackageJson} from './helper_tasks.mjs';
|
||||
|
||||
const {posixPath, quote} = require('../helpers');
|
||||
import {posixPath, quote} from '../helpers.js';
|
||||
|
||||
const argv = yargs(hideBin(process.argv)).parse();
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// Build //
|
||||
@@ -182,7 +185,7 @@ function stripApacheLicense() {
|
||||
// Closure Compiler preserves dozens of Apache licences in the Blockly code.
|
||||
// Remove these if they belong to Google or MIT.
|
||||
// MIT's permission to do this is logged in Blockly issue #2412.
|
||||
return gulp.replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n');
|
||||
return replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n');
|
||||
// Replace with the same number of lines so that source-maps are not affected.
|
||||
}
|
||||
|
||||
@@ -240,7 +243,6 @@ const JSCOMP_ERROR = [
|
||||
'underscore',
|
||||
'unknownDefines',
|
||||
// 'unusedLocalVariables', // Disabled; see note in JSCOMP_OFF.
|
||||
'unusedPrivateMembers',
|
||||
'uselessCode',
|
||||
'untranspilableFeatures',
|
||||
// 'visibility', // Disabled; see note in JSCOMP_OFF.
|
||||
@@ -306,7 +308,7 @@ const JSCOMP_OFF = [
|
||||
* Builds Blockly as a JS program, by running tsc on all the files in
|
||||
* the core directory.
|
||||
*/
|
||||
function buildJavaScript(done) {
|
||||
export function tsc(done) {
|
||||
execSync(
|
||||
`tsc -outDir "${TSC_OUTPUT_DIR}" -declarationDir "${TYPINGS_BUILD_DIR}"`,
|
||||
{stdio: 'inherit'});
|
||||
@@ -318,7 +320,7 @@ function buildJavaScript(done) {
|
||||
* This task regenerates msg/json/en.js and msg/json/qqq.js from
|
||||
* msg/messages.js.
|
||||
*/
|
||||
function generateMessages(done) {
|
||||
export function messages(done) {
|
||||
// Run js_to_json.py
|
||||
const jsToJsonCmd = `${PYTHON} scripts/i18n/js_to_json.py \
|
||||
--input_file ${path.join('msg', 'messages.js')} \
|
||||
@@ -573,10 +575,10 @@ function buildCompiled() {
|
||||
// Fire up compilation pipline.
|
||||
return gulp.src(chunkOptions.js, {base: './'})
|
||||
.pipe(stripApacheLicense())
|
||||
.pipe(gulp.sourcemaps.init())
|
||||
.pipe(sourcemaps.init())
|
||||
.pipe(compile(options))
|
||||
.pipe(gulp.rename({suffix: COMPILED_SUFFIX}))
|
||||
.pipe(gulp.sourcemaps.write('.'))
|
||||
.pipe(rename({suffix: COMPILED_SUFFIX}))
|
||||
.pipe(sourcemaps.write('.'))
|
||||
.pipe(gulp.dest(RELEASE_DIR));
|
||||
}
|
||||
|
||||
@@ -668,7 +670,7 @@ async function buildLangfileShims() {
|
||||
// (We have to do it this way because messages.js is a script and
|
||||
// not a CJS module with exports.)
|
||||
globalThis.Blockly = {Msg: {}};
|
||||
require('../../msg/messages.js');
|
||||
await import('../../msg/messages.js');
|
||||
const exportedNames = Object.keys(globalThis.Blockly.Msg);
|
||||
delete globalThis.Blockly;
|
||||
|
||||
@@ -689,12 +691,14 @@ ${exportedNames.map((name) => ` ${name},`).join('\n')}
|
||||
}
|
||||
|
||||
/**
|
||||
* This task builds Blockly core, blocks and generators together and uses
|
||||
* Closure Compiler's ADVANCED_COMPILATION mode.
|
||||
* This task uses Closure Compiler's ADVANCED_COMPILATION mode to
|
||||
* compile together Blockly core, blocks and generators with a simple
|
||||
* test app; the purpose is to verify that Blockly is compatible with
|
||||
* the ADVANCED_COMPILATION mode.
|
||||
*
|
||||
* Prerequisite: buildJavaScript.
|
||||
*/
|
||||
function buildAdvancedCompilationTest() {
|
||||
function compileAdvancedCompilationTest() {
|
||||
// If main_compressed.js exists (from a previous run) delete it so that
|
||||
// a later browser-based test won't check it should the compile fail.
|
||||
try {
|
||||
@@ -718,9 +722,9 @@ function buildAdvancedCompilationTest() {
|
||||
};
|
||||
return gulp.src(srcs, {base: './'})
|
||||
.pipe(stripApacheLicense())
|
||||
.pipe(gulp.sourcemaps.init())
|
||||
.pipe(sourcemaps.init())
|
||||
.pipe(compile(options))
|
||||
.pipe(gulp.sourcemaps.write(
|
||||
.pipe(sourcemaps.write(
|
||||
'.', {includeContent: false, sourceRoot: '../../'}))
|
||||
.pipe(gulp.dest('./tests/compile/'));
|
||||
}
|
||||
@@ -728,7 +732,7 @@ function buildAdvancedCompilationTest() {
|
||||
/**
|
||||
* This task cleans the build directory (by deleting it).
|
||||
*/
|
||||
function cleanBuildDir() {
|
||||
export function cleanBuildDir() {
|
||||
// Sanity check.
|
||||
if (BUILD_DIR === '.' || BUILD_DIR === '/') {
|
||||
return Promise.reject(`Refusing to rm -rf ${BUILD_DIR}`);
|
||||
@@ -737,16 +741,13 @@ function cleanBuildDir() {
|
||||
}
|
||||
|
||||
// Main sequence targets. Each should invoke any immediate prerequisite(s).
|
||||
exports.cleanBuildDir = cleanBuildDir;
|
||||
exports.langfiles = gulp.parallel(buildLangfiles, buildLangfileShims);
|
||||
exports.tsc = buildJavaScript;
|
||||
exports.minify = gulp.series(exports.tsc, buildCompiled, buildShims);
|
||||
exports.build = gulp.parallel(exports.minify, exports.langfiles);
|
||||
// function cleanBuildDir, above
|
||||
export const langfiles = gulp.parallel(buildLangfiles, buildLangfileShims);
|
||||
export const minify = gulp.series(tsc, buildCompiled, buildShims);
|
||||
// function tsc, above
|
||||
export const build = gulp.parallel(minify, langfiles);
|
||||
|
||||
// Manually-invokable targets, with prerequisites where required.
|
||||
exports.messages = generateMessages; // Generate msg/json/en.json et al.
|
||||
exports.buildAdvancedCompilationTest =
|
||||
gulp.series(exports.tsc, buildAdvancedCompilationTest);
|
||||
|
||||
// Targets intended only for invocation by scripts; may omit prerequisites.
|
||||
exports.onlyBuildAdvancedCompilationTest = buildAdvancedCompilationTest;
|
||||
// function messages, above
|
||||
export const buildAdvancedCompilationTest =
|
||||
gulp.series(tsc, compileAdvancedCompilationTest);
|
||||
@@ -8,7 +8,7 @@
|
||||
* @fileoverview Common configuration for Gulp scripts.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
import * as path from 'path';
|
||||
|
||||
// Paths are all relative to the repository root. Do not include
|
||||
// trailing slash.
|
||||
@@ -21,21 +21,21 @@ const path = require('path');
|
||||
// - tests/scripts/update_metadata.sh
|
||||
|
||||
// Directory to write compiled output to.
|
||||
exports.BUILD_DIR = 'build';
|
||||
export const BUILD_DIR = 'build';
|
||||
|
||||
// Directory to write typings output to.
|
||||
exports.TYPINGS_BUILD_DIR = path.join(exports.BUILD_DIR, 'declarations');
|
||||
export const TYPINGS_BUILD_DIR = path.join(BUILD_DIR, 'declarations');
|
||||
|
||||
// Directory to write langfile output to.
|
||||
exports.LANG_BUILD_DIR = path.join(exports.BUILD_DIR, 'msg');
|
||||
export const LANG_BUILD_DIR = path.join(BUILD_DIR, 'msg');
|
||||
|
||||
// Directory where typescript compiler output can be found.
|
||||
// Matches the value in tsconfig.json: outDir
|
||||
exports.TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'src');
|
||||
export const TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'src');
|
||||
|
||||
// Directory for files generated by compiling test code.
|
||||
exports.TEST_TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'tests');
|
||||
export const TEST_TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'tests');
|
||||
|
||||
// Directory in which to assemble (and from which to publish) the
|
||||
// blockly npm package.
|
||||
exports.RELEASE_DIR = 'dist';
|
||||
export const RELEASE_DIR = 'dist';
|
||||
@@ -1,9 +1,9 @@
|
||||
const {execSync} = require('child_process');
|
||||
const {Extractor} = require('markdown-tables-to-json');
|
||||
const fs = require('fs');
|
||||
const gulp = require('gulp');
|
||||
const header = require('gulp-header');
|
||||
const replace = require('gulp-replace');
|
||||
import {execSync} from 'child_process';
|
||||
import {Extractor} from 'markdown-tables-to-json';
|
||||
import * as fs from 'fs';
|
||||
import * as gulp from 'gulp';
|
||||
import * as header from 'gulp-header';
|
||||
import * as replace from 'gulp-replace';
|
||||
|
||||
const DOCS_DIR = 'docs';
|
||||
|
||||
@@ -140,8 +140,7 @@ const createToc = function(done) {
|
||||
done();
|
||||
}
|
||||
|
||||
const docs = gulp.series(
|
||||
export const docs = gulp.series(
|
||||
generateApiJson, removeRenames, generateDocs,
|
||||
gulp.parallel(prependBook, createToc));
|
||||
|
||||
module.exports = {docs};
|
||||
@@ -8,11 +8,11 @@
|
||||
* @fileoverview Git-related gulp tasks for Blockly.
|
||||
*/
|
||||
|
||||
const gulp = require('gulp');
|
||||
const execSync = require('child_process').execSync;
|
||||
import * as gulp from 'gulp';
|
||||
import {execSync} from 'child_process';
|
||||
|
||||
const buildTasks = require('./build_tasks');
|
||||
const packageTasks = require('./package_tasks');
|
||||
import * as buildTasks from './build_tasks.mjs';
|
||||
import * as packageTasks from './package_tasks.mjs';
|
||||
|
||||
const UPSTREAM_URL = 'https://github.com/google/blockly.git';
|
||||
|
||||
@@ -63,7 +63,7 @@ function syncBranch(branchName) {
|
||||
* Stash current state, check out develop, and sync with
|
||||
* google/blockly.
|
||||
*/
|
||||
function syncDevelop() {
|
||||
export function syncDevelop() {
|
||||
return syncBranch('develop');
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ function syncDevelop() {
|
||||
* Stash current state, check out master, and sync with
|
||||
* google/blockly.
|
||||
*/
|
||||
function syncMaster() {
|
||||
export function syncMaster() {
|
||||
return syncBranch('master');
|
||||
};
|
||||
|
||||
@@ -111,7 +111,7 @@ function checkoutBranch(branchName) {
|
||||
* Create and push an RC branch.
|
||||
* Note that this pushes to google/blockly.
|
||||
*/
|
||||
const createRC = gulp.series(
|
||||
export const createRC = gulp.series(
|
||||
syncDevelop(),
|
||||
function(done) {
|
||||
const branchName = getRCBranchName();
|
||||
@@ -122,7 +122,7 @@ const createRC = gulp.series(
|
||||
);
|
||||
|
||||
/** Create the rebuild branch. */
|
||||
function createRebuildBranch(done) {
|
||||
export function createRebuildBranch(done) {
|
||||
const branchName = getRebuildBranchName();
|
||||
console.log(`make-rebuild-branch: creating branch ${branchName}`);
|
||||
execSync(`git switch -C ${branchName}`, { stdio: 'inherit' });
|
||||
@@ -130,7 +130,7 @@ function createRebuildBranch(done) {
|
||||
}
|
||||
|
||||
/** Push the rebuild branch to origin. */
|
||||
function pushRebuildBranch(done) {
|
||||
export function pushRebuildBranch(done) {
|
||||
console.log('push-rebuild-branch: committing rebuild');
|
||||
execSync('git commit -am "Rebuild"', { stdio: 'inherit' });
|
||||
const branchName = getRebuildBranchName();
|
||||
@@ -145,7 +145,7 @@ function pushRebuildBranch(done) {
|
||||
*
|
||||
* Prerequisites (invoked): clean, build.
|
||||
*/
|
||||
const updateGithubPages = gulp.series(
|
||||
export const updateGithubPages = gulp.series(
|
||||
function(done) {
|
||||
execSync('git stash save -m "Stash for sync"', { stdio: 'inherit' });
|
||||
execSync('git switch -C gh-pages', { stdio: 'inherit' });
|
||||
@@ -165,17 +165,3 @@ const updateGithubPages = gulp.series(
|
||||
done();
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
// Main sequence targets. Each should invoke any immediate prerequisite(s).
|
||||
updateGithubPages,
|
||||
|
||||
// Manually-invokable targets that invoke prerequisites.
|
||||
createRC,
|
||||
|
||||
// Legacy script-only targets, to be deleted.
|
||||
syncDevelop,
|
||||
syncMaster,
|
||||
createRebuildBranch,
|
||||
pushRebuildBranch,
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2021 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Any gulp helper functions.
|
||||
*/
|
||||
|
||||
// Clears the require cache to ensure the package.json is up to date.
|
||||
function getPackageJson() {
|
||||
delete require.cache[require.resolve('../../package.json')]
|
||||
return require('../../package.json');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPackageJson: getPackageJson
|
||||
}
|
||||
25
scripts/gulpfiles/helper_tasks.mjs
Normal file
25
scripts/gulpfiles/helper_tasks.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2021 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Any gulp helper functions.
|
||||
*/
|
||||
|
||||
import Module from "node:module";
|
||||
|
||||
const require = Module.createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Load and return the contents of package.json.
|
||||
*
|
||||
* Uses require() rather than import, and clears the require cache, to
|
||||
* ensure the loaded package.json data is up to date.
|
||||
*/
|
||||
export function getPackageJson() {
|
||||
delete require.cache[require.resolve('../../package.json')];
|
||||
return require('../../package.json');
|
||||
}
|
||||
|
||||
@@ -8,20 +8,17 @@
|
||||
* @fileoverview Gulp tasks to package Blockly for distribution on NPM.
|
||||
*/
|
||||
|
||||
const gulp = require('gulp');
|
||||
gulp.concat = require('gulp-concat');
|
||||
gulp.replace = require('gulp-replace');
|
||||
gulp.rename = require('gulp-rename');
|
||||
gulp.insert = require('gulp-insert');
|
||||
gulp.umd = require('gulp-umd');
|
||||
gulp.replace = require('gulp-replace');
|
||||
import * as gulp from 'gulp';
|
||||
import concat from 'gulp-concat';
|
||||
import replace from 'gulp-replace';
|
||||
import umd from 'gulp-umd';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const {rimraf} = require('rimraf');
|
||||
const build = require('./build_tasks');
|
||||
const {getPackageJson} = require('./helper_tasks');
|
||||
const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} = require('./config');
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import {rimraf} from 'rimraf';
|
||||
import * as build from './build_tasks.mjs';
|
||||
import {getPackageJson} from './helper_tasks.mjs';
|
||||
import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} from './config.mjs';
|
||||
|
||||
// Path to template files for gulp-umd.
|
||||
const TEMPLATE_DIR = 'scripts/package/templates';
|
||||
@@ -32,7 +29,7 @@ const TEMPLATE_DIR = 'scripts/package/templates';
|
||||
* @param {Array<Object>} dependencies An array of dependencies to inject.
|
||||
*/
|
||||
function packageUMD(namespace, dependencies, template = 'umd.template') {
|
||||
return gulp.umd({
|
||||
return umd({
|
||||
dependencies: function () { return dependencies; },
|
||||
namespace: function () { return namespace; },
|
||||
exports: function () { return namespace; },
|
||||
@@ -88,7 +85,7 @@ function packageCoreNode() {
|
||||
function packageLocales() {
|
||||
// Remove references to goog.provide and goog.require.
|
||||
return gulp.src(`${LANG_BUILD_DIR}/*.js`)
|
||||
.pipe(gulp.replace(/goog\.[^\n]+/g, ''))
|
||||
.pipe(replace(/goog\.[^\n]+/g, ''))
|
||||
.pipe(packageUMD('Blockly.Msg', [], 'umd-msg.template'))
|
||||
.pipe(gulp.dest(`${RELEASE_DIR}/msg`));
|
||||
};
|
||||
@@ -107,7 +104,7 @@ function packageUMDBundle() {
|
||||
`${RELEASE_DIR}/javascript_compressed.js`,
|
||||
];
|
||||
return gulp.src(srcs)
|
||||
.pipe(gulp.concat('blockly.min.js'))
|
||||
.pipe(concat('blockly.min.js'))
|
||||
.pipe(gulp.dest(`${RELEASE_DIR}`));
|
||||
};
|
||||
|
||||
@@ -140,7 +137,7 @@ function packageUMDBundle() {
|
||||
* @param {Function} done Callback to call when done.
|
||||
*/
|
||||
function packageLegacyEntrypoints(done) {
|
||||
for (entrypoint of [
|
||||
for (const entrypoint of [
|
||||
'core', 'blocks', 'dart', 'javascript', 'lua', 'php', 'python'
|
||||
]) {
|
||||
const bundle =
|
||||
@@ -218,14 +215,14 @@ function packageDTS() {
|
||||
.pipe(gulp.src(`${TYPINGS_BUILD_DIR}/**/*.d.ts`, {ignore: [
|
||||
`${TYPINGS_BUILD_DIR}/blocks/**/*`,
|
||||
]}))
|
||||
.pipe(gulp.replace('AnyDuringMigration', 'any'))
|
||||
.pipe(replace('AnyDuringMigration', 'any'))
|
||||
.pipe(gulp.dest(RELEASE_DIR));
|
||||
};
|
||||
|
||||
/**
|
||||
* This task cleans the release directory (by deleting it).
|
||||
*/
|
||||
function cleanReleaseDir() {
|
||||
export function cleanReleaseDir() {
|
||||
// Sanity check.
|
||||
if (RELEASE_DIR === '.' || RELEASE_DIR === '/') {
|
||||
return Promise.reject(`Refusing to rm -rf ${RELEASE_DIR}`);
|
||||
@@ -237,9 +234,13 @@ function cleanReleaseDir() {
|
||||
* This task prepares the files to be included in the NPM by copying
|
||||
* them into the release directory.
|
||||
*
|
||||
* This task was formerly called "package" but was renamed in
|
||||
* preparation for porting gulpfiles to ESM because "package" is a
|
||||
* reserved word.
|
||||
*
|
||||
* Prerequisite: build.
|
||||
*/
|
||||
const package = gulp.series(
|
||||
export const pack = gulp.series(
|
||||
gulp.parallel(
|
||||
build.cleanBuildDir,
|
||||
cleanReleaseDir),
|
||||
@@ -254,9 +255,3 @@ const package = gulp.series(
|
||||
packageReadme,
|
||||
packageDTS)
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
// Main sequence targets. Each should invoke any immediate prerequisite(s).
|
||||
cleanReleaseDir: cleanReleaseDir,
|
||||
package: package,
|
||||
};
|
||||
@@ -8,15 +8,15 @@
|
||||
* @fileoverview Gulp scripts for releasing Blockly.
|
||||
*/
|
||||
|
||||
const execSync = require('child_process').execSync;
|
||||
const fs = require('fs');
|
||||
const gulp = require('gulp');
|
||||
const readlineSync = require('readline-sync');
|
||||
import {execSync} from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as gulp from 'gulp';
|
||||
import * as readlineSync from 'readline-sync';
|
||||
|
||||
const gitTasks = require('./git_tasks');
|
||||
const packageTasks = require('./package_tasks');
|
||||
const {getPackageJson} = require('./helper_tasks');
|
||||
const {RELEASE_DIR} = require('./config');
|
||||
import * as gitTasks from './git_tasks.mjs';
|
||||
import * as packageTasks from './package_tasks.mjs';
|
||||
import {getPackageJson} from './helper_tasks.mjs';
|
||||
import {RELEASE_DIR} from './config.mjs';
|
||||
|
||||
|
||||
// Gets the current major version.
|
||||
@@ -147,17 +147,17 @@ function updateBetaVersion(done) {
|
||||
}
|
||||
|
||||
// Rebuild, package and publish to npm.
|
||||
const publish = gulp.series(
|
||||
packageTasks.package, // Does clean + build.
|
||||
export const publish = gulp.series(
|
||||
packageTasks.pack, // Does clean + build.
|
||||
checkBranch,
|
||||
checkReleaseDir,
|
||||
loginAndPublish
|
||||
);
|
||||
|
||||
// Rebuild, package and publish a beta version of Blockly.
|
||||
const publishBeta = gulp.series(
|
||||
export const publishBeta = gulp.series(
|
||||
updateBetaVersion,
|
||||
packageTasks.package, // Does clean + build.
|
||||
packageTasks.pack, // Does clean + build.
|
||||
checkBranch,
|
||||
checkReleaseDir,
|
||||
loginAndPublishBeta
|
||||
@@ -165,19 +165,10 @@ const publishBeta = gulp.series(
|
||||
|
||||
// Switch to a new branch, update the version number, build Blockly
|
||||
// and check in the resulting built files.
|
||||
const recompileDevelop = gulp.series(
|
||||
export const recompile = gulp.series(
|
||||
gitTasks.syncDevelop(),
|
||||
gitTasks.createRebuildBranch,
|
||||
updateVersionPrompt,
|
||||
packageTasks.package, // Does clean + build.
|
||||
packageTasks.pack, // Does clean + build.
|
||||
gitTasks.pushRebuildBranch
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
// Main sequence targets. Each should invoke any immediate prerequisite(s).
|
||||
publishBeta,
|
||||
publish,
|
||||
|
||||
// Legacy target, to be deleted.
|
||||
recompile: recompileDevelop,
|
||||
};
|
||||
@@ -9,19 +9,19 @@
|
||||
*/
|
||||
/* eslint-env node */
|
||||
|
||||
const asyncDone = require('async-done');
|
||||
const gulp = require('gulp');
|
||||
const gzip = require('gulp-gzip');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {execSync} = require('child_process');
|
||||
const {rimraf} = require('rimraf');
|
||||
import asyncDone from 'async-done';
|
||||
import * as gulp from 'gulp';
|
||||
import gzip from 'gulp-gzip';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {execSync} from 'child_process';
|
||||
import {rimraf} from 'rimraf';
|
||||
|
||||
const {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} = require('./config');
|
||||
import {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} from './config.mjs';
|
||||
|
||||
const {runMochaTestsInBrowser} = require('../../tests/mocha/webdriver.js');
|
||||
const {runGeneratorsInBrowser} = require('../../tests/generators/webdriver.js');
|
||||
const {runCompileCheckInBrowser} = require('../../tests/compile/webdriver.js');
|
||||
import {runMochaTestsInBrowser} from '../../tests/mocha/webdriver.js';
|
||||
import {runGeneratorsInBrowser} from '../../tests/generators/webdriver.js';
|
||||
import {runCompileCheckInBrowser} from '../../tests/compile/webdriver.js';
|
||||
|
||||
const OUTPUT_DIR = 'build/generators';
|
||||
const GOLDEN_DIR = 'tests/generators/golden';
|
||||
@@ -321,7 +321,7 @@ function checkResult(suffix) {
|
||||
* Run generator tests inside a browser and check the results.
|
||||
* @return {Promise} Asynchronous result.
|
||||
*/
|
||||
async function generators() {
|
||||
export async function generators() {
|
||||
return runTestTask('generators', async () => {
|
||||
// Clean up.
|
||||
rimraf.sync(OUTPUT_DIR);
|
||||
@@ -396,10 +396,6 @@ const tasks = [
|
||||
advancedCompileInBrowser
|
||||
];
|
||||
|
||||
const test = gulp.series(...tasks, reportTestResult);
|
||||
export const test = gulp.series(...tasks, reportTestResult);
|
||||
|
||||
|
||||
module.exports = {
|
||||
test,
|
||||
generators,
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import * as chai from 'chai';
|
||||
import {Key} from 'webdriverio';
|
||||
import {
|
||||
clickBlock,
|
||||
clickWorkspace,
|
||||
contextMenuSelect,
|
||||
getAllBlocks,
|
||||
getBlockElementById,
|
||||
@@ -176,8 +177,7 @@ suite('Delete blocks', function (done) {
|
||||
);
|
||||
});
|
||||
|
||||
// TODO(#9029) enable this test once deleting a block doesn't lose focus
|
||||
test.skip('Undo block deletion', async function () {
|
||||
test('Undo block deletion', async function () {
|
||||
const before = (await getAllBlocks(this.browser)).length;
|
||||
// Get first print block, click to select it, and delete it using backspace key.
|
||||
await clickBlock(this.browser, this.firstBlock.id, {button: 1});
|
||||
@@ -204,6 +204,7 @@ suite('Delete blocks', function (done) {
|
||||
await this.browser.keys([Key.Ctrl, 'z']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
// Redo
|
||||
await clickWorkspace(this.browser);
|
||||
await this.browser.keys([Key.Ctrl, Key.Shift, 'z']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
const after = (await getAllBlocks(this.browser)).length;
|
||||
|
||||
@@ -40,8 +40,7 @@ suite('This tests loading Large Configuration and Deletion', function (done) {
|
||||
chai.assert.equal(allBlocks.length, 10);
|
||||
});
|
||||
|
||||
// TODO(#8793) Re-enable test after deleting a block updates focus correctly.
|
||||
test.skip('undoing delete block results in the correct number of blocks', async function () {
|
||||
test('undoing delete block results in the correct number of blocks', async function () {
|
||||
await this.browser.keys([Key.Ctrl, 'z']);
|
||||
await this.browser.pause(PAUSE_TIME);
|
||||
const allBlocks = await getAllBlocks(this.browser);
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
import * as chai from 'chai';
|
||||
import {
|
||||
connect,
|
||||
getBlockTypeFromCategory,
|
||||
getNthBlockOfCategory,
|
||||
getSelectedBlockElement,
|
||||
dragBlockTypeFromFlyout,
|
||||
dragNthBlockFromFlyout,
|
||||
PAUSE_TIME,
|
||||
testFileLocations,
|
||||
testSetup,
|
||||
@@ -33,43 +32,41 @@ suite('Testing Connecting Blocks', function (done) {
|
||||
|
||||
test('Testing Procedure', async function () {
|
||||
// Drag out first function
|
||||
let proceduresDefReturn = await getBlockTypeFromCategory(
|
||||
const doSomething = await dragBlockTypeFromFlyout(
|
||||
this.browser,
|
||||
'Functions',
|
||||
'procedures_defreturn',
|
||||
50,
|
||||
20,
|
||||
);
|
||||
await proceduresDefReturn.dragAndDrop({x: 50, y: 20});
|
||||
const doSomething = await getSelectedBlockElement(this.browser);
|
||||
|
||||
// Drag out second function.
|
||||
proceduresDefReturn = await getBlockTypeFromCategory(
|
||||
const doSomething2 = await dragBlockTypeFromFlyout(
|
||||
this.browser,
|
||||
'Functions',
|
||||
'procedures_defreturn',
|
||||
50,
|
||||
20,
|
||||
);
|
||||
await proceduresDefReturn.dragAndDrop({x: 300, y: 200});
|
||||
const doSomething2 = await getSelectedBlockElement(this.browser);
|
||||
|
||||
// Drag out numeric
|
||||
const mathNumeric = await getBlockTypeFromCategory(
|
||||
const numeric = await dragBlockTypeFromFlyout(
|
||||
this.browser,
|
||||
'Math',
|
||||
'math_number',
|
||||
50,
|
||||
20,
|
||||
);
|
||||
await mathNumeric.dragAndDrop({x: 50, y: 20});
|
||||
const numeric = await getSelectedBlockElement(this.browser);
|
||||
|
||||
// Connect numeric to first procedure
|
||||
await connect(this.browser, numeric, 'OUTPUT', doSomething, 'RETURN');
|
||||
|
||||
// Drag out doSomething caller from flyout.
|
||||
const doSomethingFlyout = await getNthBlockOfCategory(
|
||||
const doSomethingCaller = await dragNthBlockFromFlyout(
|
||||
this.browser,
|
||||
'Functions',
|
||||
3,
|
||||
50,
|
||||
20,
|
||||
);
|
||||
await doSomethingFlyout.dragAndDrop({x: 50, y: 20});
|
||||
const doSomethingCaller = await getSelectedBlockElement(this.browser);
|
||||
|
||||
// Connect the doSomething caller to doSomething2
|
||||
await connect(
|
||||
@@ -81,22 +78,22 @@ suite('Testing Connecting Blocks', function (done) {
|
||||
);
|
||||
|
||||
// Drag out print from flyout.
|
||||
const printFlyout = await getBlockTypeFromCategory(
|
||||
const print = await dragBlockTypeFromFlyout(
|
||||
this.browser,
|
||||
'Text',
|
||||
'text_print',
|
||||
50,
|
||||
0,
|
||||
);
|
||||
await printFlyout.dragAndDrop({x: 50, y: 20});
|
||||
const print = await getSelectedBlockElement(this.browser);
|
||||
|
||||
// Drag out doSomething2 caller from flyout.
|
||||
const doSomething2Flyout = await getNthBlockOfCategory(
|
||||
const doSomething2Caller = await dragNthBlockFromFlyout(
|
||||
this.browser,
|
||||
'Functions',
|
||||
4,
|
||||
50,
|
||||
20,
|
||||
);
|
||||
await doSomething2Flyout.dragAndDrop({x: 130, y: 20});
|
||||
const doSomething2Caller = await getSelectedBlockElement(this.browser);
|
||||
|
||||
// Connect doSomething2 caller with print.
|
||||
await connect(this.browser, doSomething2Caller, 'OUTPUT', print, 'TEXT');
|
||||
|
||||
@@ -62,6 +62,8 @@ export async function driverSetup() {
|
||||
// Use Selenium to bring up the page
|
||||
console.log('Starting webdriverio...');
|
||||
driver = await webdriverio.remote(options);
|
||||
driver.setWindowSize(800, 600);
|
||||
driver.setViewport({width: 800, height: 600});
|
||||
return driver;
|
||||
}
|
||||
|
||||
@@ -170,43 +172,52 @@ export async function getBlockElementById(browser, id) {
|
||||
* @return A Promise that resolves when the actions are completed.
|
||||
*/
|
||||
export async function clickBlock(browser, blockId, clickOptions) {
|
||||
const findableId = 'clickTargetElement';
|
||||
// In the browser context, find the element that we want and give it a findable ID.
|
||||
await browser.execute(
|
||||
(blockId, newElemId) => {
|
||||
const block = Blockly.getMainWorkspace().getBlockById(blockId);
|
||||
// Ensure the block we want to click is within the viewport.
|
||||
Blockly.getMainWorkspace().scrollBoundsIntoView(
|
||||
block.getBoundingRectangleWithoutChildren(),
|
||||
10,
|
||||
);
|
||||
const elem = await getTargetableBlockElement(browser, blockId, false);
|
||||
await elem.click(clickOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an element on the block that is suitable for a click or drag.
|
||||
*
|
||||
* We can't always use the block's SVG root because clicking will always happen
|
||||
* in the middle of the block's bounds (including children) by default, which
|
||||
* causes problems if it has holes (e.g. statement inputs). Instead, this tries
|
||||
* to get the first text field on the block. It falls back on the block's SVG root.
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @param blockId The id of the block to click, as an interactable element.
|
||||
* @param toolbox True if this block is in the toolbox (which must be open already).
|
||||
* @return A Promise that returns an appropriate element.
|
||||
*/
|
||||
async function getTargetableBlockElement(browser, blockId, toolbox) {
|
||||
const id = await browser.execute(
|
||||
(blockId, toolbox, newElemId) => {
|
||||
const ws = toolbox
|
||||
? Blockly.getMainWorkspace().getFlyout().getWorkspace()
|
||||
: Blockly.getMainWorkspace();
|
||||
const block = ws.getBlockById(blockId);
|
||||
// Ensure the block we want to click/drag is within the viewport.
|
||||
ws.scrollBoundsIntoView(block.getBoundingRectangleWithoutChildren(), 10);
|
||||
if (!block.isCollapsed()) {
|
||||
for (const input of block.inputList) {
|
||||
for (const field of input.fieldRow) {
|
||||
if (field instanceof Blockly.FieldLabel) {
|
||||
field.getSvgRoot().id = newElemId;
|
||||
return;
|
||||
// Expose the id of the element we want to target
|
||||
field.getSvgRoot().setAttribute('data-id', field.id_);
|
||||
return field.getSvgRoot().id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No label field found. Fall back to the block's SVG root.
|
||||
block.getSvgRoot().id = newElemId;
|
||||
// No label field found. Fall back to the block's SVG root, which should
|
||||
// already use the block id.
|
||||
return block.id;
|
||||
},
|
||||
blockId,
|
||||
findableId,
|
||||
toolbox,
|
||||
);
|
||||
|
||||
// In the test context, get the Webdriverio Element that we've identified.
|
||||
const elem = await browser.$(`#${findableId}`);
|
||||
|
||||
await elem.click(clickOptions);
|
||||
|
||||
// In the browser context, remove the ID.
|
||||
await browser.execute((elemId) => {
|
||||
const clickElem = document.getElementById(elemId);
|
||||
clickElem.removeAttribute('id');
|
||||
}, findableId);
|
||||
return await getBlockElementById(browser, id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,7 +226,7 @@ export async function clickBlock(browser, blockId, clickOptions) {
|
||||
* @return A Promise that resolves when the actions are completed.
|
||||
*/
|
||||
export async function clickWorkspace(browser) {
|
||||
const workspace = await browser.$('#blocklyDiv > div > svg.blocklySvg > g');
|
||||
const workspace = await browser.$('svg.blocklySvg > g');
|
||||
await workspace.click();
|
||||
await browser.pause(PAUSE_TIME);
|
||||
}
|
||||
@@ -253,27 +264,14 @@ export async function getCategory(browser, categoryName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @param categoryName The name of the toolbox category to search.
|
||||
* @param n Which block to select, 0-indexed from the top of the category.
|
||||
* @return A Promise that resolves to the root element of the nth
|
||||
* block in the given category.
|
||||
*/
|
||||
export async function getNthBlockOfCategory(browser, categoryName, n) {
|
||||
const category = await getCategory(browser, categoryName);
|
||||
await category.click();
|
||||
const block = (
|
||||
await browser.$$(`.blocklyFlyout .blocklyBlockCanvas > .blocklyDraggable`)
|
||||
)[n];
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the specified category, finds the first block of the given type,
|
||||
* scrolls it into view, and returns a draggable element on that block.
|
||||
*
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @param categoryName The name of the toolbox category to search.
|
||||
* Null if the toolbox has no categories (simple).
|
||||
* @param blockType The type of the block to search for.
|
||||
* @return A Promise that resolves to the root element of the first
|
||||
* @return A Promise that resolves to a draggable element of the first
|
||||
* block with the given type in the given category.
|
||||
*/
|
||||
export async function getBlockTypeFromCategory(
|
||||
@@ -286,13 +284,14 @@ export async function getBlockTypeFromCategory(
|
||||
await category.click();
|
||||
}
|
||||
|
||||
await browser.pause(PAUSE_TIME);
|
||||
const id = await browser.execute((blockType) => {
|
||||
return Blockly.getMainWorkspace()
|
||||
.getFlyout()
|
||||
.getWorkspace()
|
||||
.getBlocksByType(blockType)[0].id;
|
||||
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
|
||||
const block = ws.getBlocksByType(blockType)[0];
|
||||
ws.scrollBoundsIntoView(block.getBoundingRectangleWithoutChildren());
|
||||
return block.id;
|
||||
}, blockType);
|
||||
return getBlockElementById(browser, id);
|
||||
return getTargetableBlockElement(browser, id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,7 +446,16 @@ export async function switchRTL(browser) {
|
||||
* created block.
|
||||
*/
|
||||
export async function dragNthBlockFromFlyout(browser, categoryName, n, x, y) {
|
||||
const flyoutBlock = await getNthBlockOfCategory(browser, categoryName, n);
|
||||
const category = await getCategory(browser, categoryName);
|
||||
await category.click();
|
||||
|
||||
await browser.pause(PAUSE_TIME);
|
||||
const id = await browser.execute((n) => {
|
||||
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
|
||||
const block = ws.getTopBlocks(true)[n];
|
||||
return block.id;
|
||||
}, n);
|
||||
const flyoutBlock = await getTargetableBlockElement(browser, id, true);
|
||||
await flyoutBlock.dragAndDrop({x: x, y: y});
|
||||
return await getSelectedBlockElement(browser);
|
||||
}
|
||||
@@ -480,6 +488,7 @@ export async function dragBlockTypeFromFlyout(
|
||||
type,
|
||||
);
|
||||
await flyoutBlock.dragAndDrop({x: x, y: y});
|
||||
await browser.pause(PAUSE_TIME);
|
||||
return await getSelectedBlockElement(browser);
|
||||
}
|
||||
|
||||
@@ -584,26 +593,3 @@ export async function getAllBlocks(browser) {
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the flyout's scrollbar and scroll by the specified amount.
|
||||
* This makes several assumptions:
|
||||
* - A flyout with a valid scrollbar exists, is open, and is in view.
|
||||
* - The workspace has a trash can, which means it has a second (hidden) flyout.
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @param xDelta How far to drag the flyout in the x direction. Positive is right.
|
||||
* @param yDelta How far to drag the flyout in the y direction. Positive is down.
|
||||
* @return A Promise that resolves when the actions are completed.
|
||||
*/
|
||||
export async function scrollFlyout(browser, xDelta, yDelta) {
|
||||
// There are two flyouts on the playground workspace: one for the trash can
|
||||
// and one for the toolbox. We want the second one.
|
||||
// This assumes there is only one scrollbar handle in the flyout, but it could
|
||||
// be either horizontal or vertical.
|
||||
await browser.pause(PAUSE_TIME);
|
||||
const scrollbarHandle = await browser
|
||||
.$$(`.blocklyFlyoutScrollbar`)[1]
|
||||
.$(`rect.blocklyScrollbarHandle`);
|
||||
await scrollbarHandle.dragAndDrop({x: xDelta, y: yDelta});
|
||||
await browser.pause(PAUSE_TIME);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
*/
|
||||
|
||||
import * as chai from 'chai';
|
||||
import {Key} from 'webdriverio';
|
||||
import {
|
||||
dragBlockTypeFromFlyout,
|
||||
getCategory,
|
||||
PAUSE_TIME,
|
||||
screenDirection,
|
||||
scrollFlyout,
|
||||
testFileLocations,
|
||||
testSetup,
|
||||
} from './test_setup.mjs';
|
||||
@@ -57,28 +58,29 @@ const testCategories = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Check whether an element is fully inside the bounds of the Blockly div. You can use this
|
||||
* to determine whether a block on the workspace or flyout is inside the Blockly div.
|
||||
* This does not check whether there are other Blockly elements (such as a toolbox or
|
||||
* flyout) on top of the element. A partially visible block is considered out of bounds.
|
||||
* Get the type of the nth block in the specified category.
|
||||
* @param browser The active WebdriverIO Browser object.
|
||||
* @param element The element to look for.
|
||||
* @returns A Promise resolving to true if the element is in bounds and false otherwise.
|
||||
* @param categoryName The name of the category to inspect.
|
||||
* @param n The index of the block to get
|
||||
* @returns A Promise resolving to the type the block in the specified
|
||||
* category's flyout at index i.
|
||||
*/
|
||||
async function elementInBounds(browser, element) {
|
||||
return await browser.execute((elem) => {
|
||||
const rect = elem.getBoundingClientRect();
|
||||
async function getNthBlockType(browser, categoryName, n) {
|
||||
const category = await getCategory(browser, categoryName);
|
||||
await category.click();
|
||||
await browser.pause(PAUSE_TIME);
|
||||
|
||||
const blocklyDiv = document.getElementById('blocklyDiv');
|
||||
const blocklyRect = blocklyDiv.getBoundingClientRect();
|
||||
const blockType = await browser.execute((i) => {
|
||||
return Blockly.getMainWorkspace()
|
||||
.getFlyout()
|
||||
.getWorkspace()
|
||||
.getTopBlocks(false)[i].type;
|
||||
}, n);
|
||||
|
||||
const vertInView =
|
||||
rect.top >= blocklyRect.top && rect.bottom <= blocklyRect.bottom;
|
||||
const horInView =
|
||||
rect.left >= blocklyRect.left && rect.right <= blocklyRect.right;
|
||||
|
||||
return vertInView && horInView;
|
||||
}, element);
|
||||
// Unicode escape to close flyout.
|
||||
await browser.keys([Key.Escape]);
|
||||
await browser.pause(PAUSE_TIME);
|
||||
return blockType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +103,7 @@ async function getBlockCount(browser, categoryName) {
|
||||
});
|
||||
|
||||
// Unicode escape to close flyout.
|
||||
await browser.keys(['\uE00C']);
|
||||
await browser.keys([Key.Escape]);
|
||||
await browser.pause(PAUSE_TIME);
|
||||
return blockCount;
|
||||
}
|
||||
@@ -141,18 +143,12 @@ async function openCategories(browser, categoryList, directionMultiplier) {
|
||||
await category.click();
|
||||
if (await isBlockDisabled(browser, i)) {
|
||||
// Unicode escape to close flyout.
|
||||
await browser.keys(['\uE00C']);
|
||||
await browser.keys([Key.Escape]);
|
||||
await browser.pause(PAUSE_TIME);
|
||||
continue;
|
||||
}
|
||||
const flyoutBlock = await browser.$(
|
||||
`.blocklyFlyout .blocklyBlockCanvas > g:nth-child(${3 + i * 2})`,
|
||||
);
|
||||
while (!(await elementInBounds(browser, flyoutBlock))) {
|
||||
await scrollFlyout(browser, 0, 50);
|
||||
}
|
||||
|
||||
await flyoutBlock.dragAndDrop({x: directionMultiplier * 50, y: 0});
|
||||
const blockType = await getNthBlockType(browser, categoryName, i);
|
||||
dragBlockTypeFromFlyout(browser, categoryName, blockType, 50, 20);
|
||||
await browser.pause(PAUSE_TIME);
|
||||
// Should be one top level block on the workspace.
|
||||
const topBlockCount = await browser.execute(() => {
|
||||
@@ -178,7 +174,9 @@ async function openCategories(browser, categoryList, directionMultiplier) {
|
||||
chai.assert.equal(failureCount, 0);
|
||||
}
|
||||
|
||||
suite('Open toolbox categories', function () {
|
||||
// TODO (#9217) These take too long to run and are very flakey. Need to find a
|
||||
// better way to test whatever this is trying to test.
|
||||
suite.skip('Open toolbox categories', function () {
|
||||
this.timeout(0);
|
||||
|
||||
test('opening every toolbox category in the category toolbox in LTR', async function () {
|
||||
|
||||
@@ -206,13 +206,13 @@ suite('Workspace comments', function () {
|
||||
'.blocklyComment .blocklyResizeHandle',
|
||||
);
|
||||
await resizeHandle.dragAndDrop(delta);
|
||||
|
||||
chai.assert.deepEqual(
|
||||
await getCommentSize(this.browser, commentId),
|
||||
{
|
||||
width: origSize.width + delta.x,
|
||||
height: origSize.height + delta.y,
|
||||
},
|
||||
const newSize = await getCommentSize(this.browser, commentId);
|
||||
chai.assert.isTrue(
|
||||
Math.abs(newSize.width - (origSize.width + delta.x)) < 1,
|
||||
'Expected the comment model size to match the resized size',
|
||||
);
|
||||
chai.assert.isTrue(
|
||||
Math.abs(newSize.height - (origSize.height + delta.y)) < 1,
|
||||
'Expected the comment model size to match the resized size',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ suite('Clipboard', function () {
|
||||
await mutatorIcon.setBubbleVisible(true);
|
||||
const mutatorWorkspace = mutatorIcon.getWorkspace();
|
||||
const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0];
|
||||
assert.notEqual(elseIf, undefined);
|
||||
assert.isDefined(elseIf);
|
||||
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
|
||||
assert.lengthOf(this.workspace.getAllBlocks(), 1);
|
||||
const data = elseIf.toCopyData();
|
||||
@@ -85,6 +85,34 @@ suite('Clipboard', function () {
|
||||
assert.lengthOf(this.workspace.getAllBlocks(), 1);
|
||||
});
|
||||
|
||||
test('pasting into a mutator flyout pastes into the mutator workspace', async function () {
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
{
|
||||
'type': 'controls_if',
|
||||
'id': 'blockId',
|
||||
'extraState': {
|
||||
'elseIfCount': 1,
|
||||
},
|
||||
},
|
||||
this.workspace,
|
||||
);
|
||||
const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR);
|
||||
await mutatorIcon.setBubbleVisible(true);
|
||||
const mutatorWorkspace = mutatorIcon.getWorkspace();
|
||||
const mutatorFlyoutWorkspace = mutatorWorkspace
|
||||
.getFlyout()
|
||||
.getWorkspace();
|
||||
const elseIf =
|
||||
mutatorFlyoutWorkspace.getBlocksByType('controls_if_elseif')[0];
|
||||
assert.isDefined(elseIf);
|
||||
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
|
||||
assert.lengthOf(this.workspace.getAllBlocks(), 1);
|
||||
const data = elseIf.toCopyData();
|
||||
Blockly.clipboard.paste(data, mutatorFlyoutWorkspace);
|
||||
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3);
|
||||
assert.lengthOf(this.workspace.getAllBlocks(), 1);
|
||||
});
|
||||
|
||||
suite('pasted blocks are placed in unambiguous locations', function () {
|
||||
test('pasted blocks are bumped to not overlap', function () {
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
@@ -139,8 +167,7 @@ suite('Clipboard', function () {
|
||||
});
|
||||
|
||||
suite('pasting comments', function () {
|
||||
// TODO: Reenable test when we readd copy-paste.
|
||||
test.skip('pasted comments are bumped to not overlap', function () {
|
||||
test('pasted comments are bumped to not overlap', function () {
|
||||
Blockly.Xml.domToWorkspace(
|
||||
Blockly.utils.xml.textToDom(
|
||||
'<xml><comment id="test" x=10 y=10/></xml>',
|
||||
@@ -153,7 +180,7 @@ suite('Clipboard', function () {
|
||||
const newComment = Blockly.clipboard.paste(data, this.workspace);
|
||||
assert.deepEqual(
|
||||
newComment.getRelativeToSurfaceXY(),
|
||||
new Blockly.utils.Coordinate(60, 60),
|
||||
new Blockly.utils.Coordinate(40, 40),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,10 @@ suite('Connection checker', function () {
|
||||
}
|
||||
|
||||
test('Target Null', function () {
|
||||
const connection = new Blockly.Connection({}, ConnectionType.INPUT_VALUE);
|
||||
const connection = new Blockly.Connection(
|
||||
{id: 'test'},
|
||||
ConnectionType.INPUT_VALUE,
|
||||
);
|
||||
assertReasonHelper(
|
||||
this.checker,
|
||||
connection,
|
||||
@@ -38,7 +41,7 @@ suite('Connection checker', function () {
|
||||
);
|
||||
});
|
||||
test('Target Self', function () {
|
||||
const block = {workspace: 1};
|
||||
const block = {id: 'test', workspace: 1};
|
||||
const connection1 = new Blockly.Connection(
|
||||
block,
|
||||
ConnectionType.INPUT_VALUE,
|
||||
@@ -57,11 +60,11 @@ suite('Connection checker', function () {
|
||||
});
|
||||
test('Different Workspaces', function () {
|
||||
const connection1 = new Blockly.Connection(
|
||||
{workspace: 1},
|
||||
{id: 'test1', workspace: 1},
|
||||
ConnectionType.INPUT_VALUE,
|
||||
);
|
||||
const connection2 = new Blockly.Connection(
|
||||
{workspace: 2},
|
||||
{id: 'test2', workspace: 2},
|
||||
ConnectionType.OUTPUT_VALUE,
|
||||
);
|
||||
|
||||
@@ -76,10 +79,10 @@ suite('Connection checker', function () {
|
||||
setup(function () {
|
||||
// We have to declare each separately so that the connections belong
|
||||
// on different blocks.
|
||||
const prevBlock = {isShadow: function () {}};
|
||||
const nextBlock = {isShadow: function () {}};
|
||||
const outBlock = {isShadow: function () {}};
|
||||
const inBlock = {isShadow: function () {}};
|
||||
const prevBlock = {id: 'test1', isShadow: function () {}};
|
||||
const nextBlock = {id: 'test2', isShadow: function () {}};
|
||||
const outBlock = {id: 'test3', isShadow: function () {}};
|
||||
const inBlock = {id: 'test4', isShadow: function () {}};
|
||||
this.previous = new Blockly.Connection(
|
||||
prevBlock,
|
||||
ConnectionType.PREVIOUS_STATEMENT,
|
||||
@@ -197,11 +200,13 @@ suite('Connection checker', function () {
|
||||
suite('Shadows', function () {
|
||||
test('Previous Shadow', function () {
|
||||
const prevBlock = {
|
||||
id: 'test1',
|
||||
isShadow: function () {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const nextBlock = {
|
||||
id: 'test2',
|
||||
isShadow: function () {
|
||||
return false;
|
||||
},
|
||||
@@ -224,11 +229,13 @@ suite('Connection checker', function () {
|
||||
});
|
||||
test('Next Shadow', function () {
|
||||
const prevBlock = {
|
||||
id: 'test1',
|
||||
isShadow: function () {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
const nextBlock = {
|
||||
id: 'test2',
|
||||
isShadow: function () {
|
||||
return true;
|
||||
},
|
||||
@@ -251,11 +258,13 @@ suite('Connection checker', function () {
|
||||
});
|
||||
test('Prev and Next Shadow', function () {
|
||||
const prevBlock = {
|
||||
id: 'test1',
|
||||
isShadow: function () {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const nextBlock = {
|
||||
id: 'test2',
|
||||
isShadow: function () {
|
||||
return true;
|
||||
},
|
||||
@@ -278,11 +287,13 @@ suite('Connection checker', function () {
|
||||
});
|
||||
test('Output Shadow', function () {
|
||||
const outBlock = {
|
||||
id: 'test1',
|
||||
isShadow: function () {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const inBlock = {
|
||||
id: 'test2',
|
||||
isShadow: function () {
|
||||
return false;
|
||||
},
|
||||
@@ -305,11 +316,13 @@ suite('Connection checker', function () {
|
||||
});
|
||||
test('Input Shadow', function () {
|
||||
const outBlock = {
|
||||
id: 'test1',
|
||||
isShadow: function () {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
const inBlock = {
|
||||
id: 'test2',
|
||||
isShadow: function () {
|
||||
return true;
|
||||
},
|
||||
@@ -332,11 +345,13 @@ suite('Connection checker', function () {
|
||||
});
|
||||
test('Output and Input Shadow', function () {
|
||||
const outBlock = {
|
||||
id: 'test1',
|
||||
isShadow: function () {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const inBlock = {
|
||||
id: 'test2',
|
||||
isShadow: function () {
|
||||
return true;
|
||||
},
|
||||
@@ -373,9 +388,11 @@ suite('Connection checker', function () {
|
||||
};
|
||||
test('Output connected, adding previous', function () {
|
||||
const outBlock = {
|
||||
id: 'test1',
|
||||
isShadow: function () {},
|
||||
};
|
||||
const inBlock = {
|
||||
id: 'test2',
|
||||
isShadow: function () {},
|
||||
};
|
||||
const outCon = new Blockly.Connection(
|
||||
@@ -394,6 +411,7 @@ suite('Connection checker', function () {
|
||||
ConnectionType.PREVIOUS_STATEMENT,
|
||||
);
|
||||
const nextBlock = {
|
||||
id: 'test3',
|
||||
isShadow: function () {},
|
||||
};
|
||||
const nextCon = new Blockly.Connection(
|
||||
@@ -410,9 +428,11 @@ suite('Connection checker', function () {
|
||||
});
|
||||
test('Previous connected, adding output', function () {
|
||||
const prevBlock = {
|
||||
id: 'test1',
|
||||
isShadow: function () {},
|
||||
};
|
||||
const nextBlock = {
|
||||
id: 'test2',
|
||||
isShadow: function () {},
|
||||
};
|
||||
const prevCon = new Blockly.Connection(
|
||||
@@ -431,6 +451,7 @@ suite('Connection checker', function () {
|
||||
ConnectionType.OUTPUT_VALUE,
|
||||
);
|
||||
const inBlock = {
|
||||
id: 'test3',
|
||||
isShadow: function () {},
|
||||
};
|
||||
const inCon = new Blockly.Connection(
|
||||
@@ -449,8 +470,14 @@ suite('Connection checker', function () {
|
||||
});
|
||||
suite('Check Types', function () {
|
||||
setup(function () {
|
||||
this.con1 = new Blockly.Connection({}, ConnectionType.PREVIOUS_STATEMENT);
|
||||
this.con2 = new Blockly.Connection({}, ConnectionType.NEXT_STATEMENT);
|
||||
this.con1 = new Blockly.Connection(
|
||||
{id: 'test1'},
|
||||
ConnectionType.PREVIOUS_STATEMENT,
|
||||
);
|
||||
this.con2 = new Blockly.Connection(
|
||||
{id: 'test2'},
|
||||
ConnectionType.NEXT_STATEMENT,
|
||||
);
|
||||
});
|
||||
function assertCheckTypes(checker, one, two) {
|
||||
assert.isTrue(checker.doTypeChecks(one, two));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import {ConnectionType} from '../../build/src/core/connection_type.js';
|
||||
import * as idGenerator from '../../build/src/core/utils/idgenerator.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
@@ -31,7 +32,7 @@ suite('Connection Database', function () {
|
||||
};
|
||||
workspace.connectionDBList[type] = opt_database || this.database;
|
||||
const connection = new Blockly.RenderedConnection(
|
||||
{workspace: workspace},
|
||||
{id: idGenerator.getNextUniqueId(), workspace: workspace},
|
||||
type,
|
||||
);
|
||||
connection.x = x;
|
||||
|
||||
@@ -355,6 +355,7 @@ suite('Events', function () {
|
||||
|
||||
suite('With variable getter blocks', function () {
|
||||
setup(function () {
|
||||
this.TEST_BLOCK_ID = 'test_block_id';
|
||||
this.genUidStub = createGenUidStubWithReturns([
|
||||
this.TEST_BLOCK_ID,
|
||||
'test_var_id',
|
||||
|
||||
@@ -294,4 +294,300 @@ suite('Text Input Fields', function () {
|
||||
this.assertValue('test text');
|
||||
});
|
||||
});
|
||||
|
||||
suite('Use editor', function () {
|
||||
setup(function () {
|
||||
this.blockJson = {
|
||||
'type': 'math_arithmetic',
|
||||
'id': 'test_arithmetic_block',
|
||||
'fields': {
|
||||
'OP': 'ADD',
|
||||
},
|
||||
'inputs': {
|
||||
'A': {
|
||||
'shadow': {
|
||||
'type': 'math_number',
|
||||
'id': 'left_input_block',
|
||||
'name': 'test_name',
|
||||
'fields': {
|
||||
'NUM': 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
'B': {
|
||||
'shadow': {
|
||||
'type': 'math_number',
|
||||
'id': 'right_input_block',
|
||||
'fields': {
|
||||
'NUM': 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
this.getFieldFromShadowBlock = function (shadowBlock) {
|
||||
return shadowBlock.getFields().next().value;
|
||||
};
|
||||
|
||||
this.simulateTypingIntoInput = (inputElem, newText) => {
|
||||
// Typing into an input field changes its value directly and then fires
|
||||
// an InputEvent (which FieldInput relies on to automatically
|
||||
// synchronize its state).
|
||||
inputElem.value = newText;
|
||||
inputElem.dispatchEvent(new InputEvent('input'));
|
||||
};
|
||||
});
|
||||
|
||||
// The block being tested doesn't use full-block fields in Geras.
|
||||
suite('Geras theme', function () {
|
||||
setup(function () {
|
||||
this.workspace = Blockly.inject('blocklyDiv', {
|
||||
renderer: 'geras',
|
||||
});
|
||||
Blockly.serialization.blocks.append(this.blockJson, this.workspace);
|
||||
|
||||
// The workspace actually needs to be visible for focus.
|
||||
document.getElementById('blocklyDiv').style.visibility = 'visible';
|
||||
});
|
||||
teardown(function () {
|
||||
document.getElementById('blocklyDiv').style.visibility = 'hidden';
|
||||
workspaceTeardown.call(this, this.workspace);
|
||||
});
|
||||
|
||||
test('No editor open by default', function () {
|
||||
// The editor is only opened if its indicated that it should be open.
|
||||
assert.isNull(document.querySelector('.blocklyHtmlInput'));
|
||||
});
|
||||
|
||||
test('Type in editor with escape does not change field value', async function () {
|
||||
const block = this.workspace.getBlockById('left_input_block');
|
||||
const field = this.getFieldFromShadowBlock(block);
|
||||
field.showEditor();
|
||||
// This must be called to avoid editor resize logic throwing an error.
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
|
||||
// Change the value of the field's input through its editor.
|
||||
const fieldEditor = document.querySelector('.blocklyHtmlInput');
|
||||
this.simulateTypingIntoInput(fieldEditor, 'updated value');
|
||||
fieldEditor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
}),
|
||||
);
|
||||
|
||||
// 'Escape' will avoid saving the edited field value and close the editor.
|
||||
assert.equal(field.getValue(), 1);
|
||||
assert.isNull(document.querySelector('.blocklyHtmlInput'));
|
||||
});
|
||||
|
||||
test('Type in editor with enter changes field value', async function () {
|
||||
const block = this.workspace.getBlockById('left_input_block');
|
||||
const field = this.getFieldFromShadowBlock(block);
|
||||
field.showEditor();
|
||||
// This must be called to avoid editor resize logic throwing an error.
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
|
||||
// Change the value of the field's input through its editor.
|
||||
const fieldEditor = document.querySelector('.blocklyHtmlInput');
|
||||
this.simulateTypingIntoInput(fieldEditor, '10');
|
||||
fieldEditor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
}),
|
||||
);
|
||||
|
||||
// 'Enter' will save the edited result and close the editor.
|
||||
assert.equal(field.getValue(), 10);
|
||||
assert.isNull(document.querySelector('.blocklyHtmlInput'));
|
||||
});
|
||||
|
||||
test('Not finishing editing does not return ephemeral focus', async function () {
|
||||
const block = this.workspace.getBlockById('left_input_block');
|
||||
const field = this.getFieldFromShadowBlock(block);
|
||||
Blockly.getFocusManager().focusNode(field);
|
||||
field.showEditor();
|
||||
// This must be called to avoid editor resize logic throwing an error.
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
|
||||
// Change the value of the field's input through its editor.
|
||||
const fieldEditor = document.querySelector('.blocklyHtmlInput');
|
||||
this.simulateTypingIntoInput(fieldEditor, '10');
|
||||
|
||||
// If the editor doesn't restore focus then the current focused element is
|
||||
// still the editor.
|
||||
assert.strictEqual(document.activeElement, fieldEditor);
|
||||
});
|
||||
|
||||
test('Finishing editing returns ephemeral focus', async function () {
|
||||
const block = this.workspace.getBlockById('left_input_block');
|
||||
const field = this.getFieldFromShadowBlock(block);
|
||||
Blockly.getFocusManager().focusNode(field);
|
||||
field.showEditor();
|
||||
// This must be called to avoid editor resize logic throwing an error.
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
|
||||
// Change the value of the field's input through its editor.
|
||||
const fieldEditor = document.querySelector('.blocklyHtmlInput');
|
||||
this.simulateTypingIntoInput(fieldEditor, '10');
|
||||
fieldEditor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that exiting the editor restores focus back to the field.
|
||||
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), field);
|
||||
assert.strictEqual(document.activeElement, field.getFocusableElement());
|
||||
});
|
||||
|
||||
test('Opening an editor, tabbing, then editing changes the second field', async function () {
|
||||
const leftInputBlock = this.workspace.getBlockById('left_input_block');
|
||||
const rightInputBlock =
|
||||
this.workspace.getBlockById('right_input_block');
|
||||
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
|
||||
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
|
||||
leftField.showEditor();
|
||||
// This must be called to avoid editor resize logic throwing an error.
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
|
||||
// Tab, then edit and close the editor.
|
||||
document.querySelector('.blocklyHtmlInput').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
}),
|
||||
);
|
||||
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
|
||||
this.simulateTypingIntoInput(rightFieldEditor, '15');
|
||||
rightFieldEditor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that only the right field changed (due to the tab).
|
||||
assert.equal(leftField.getValue(), 1);
|
||||
assert.equal(rightField.getValue(), 15);
|
||||
assert.isNull(document.querySelector('.blocklyHtmlInput'));
|
||||
});
|
||||
|
||||
test('Opening an editor, tabbing, then editing changes focus to the second field', async function () {
|
||||
const leftInputBlock = this.workspace.getBlockById('left_input_block');
|
||||
const rightInputBlock =
|
||||
this.workspace.getBlockById('right_input_block');
|
||||
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
|
||||
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
|
||||
Blockly.getFocusManager().focusNode(leftField);
|
||||
leftField.showEditor();
|
||||
// This must be called to avoid editor resize logic throwing an error.
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
|
||||
// Tab, then edit and close the editor.
|
||||
document.querySelector('.blocklyHtmlInput').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
}),
|
||||
);
|
||||
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
|
||||
this.simulateTypingIntoInput(rightFieldEditor, '15');
|
||||
rightFieldEditor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that the tab causes focus to change to the right field.
|
||||
assert.strictEqual(
|
||||
Blockly.getFocusManager().getFocusedNode(),
|
||||
rightField,
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
rightField.getFocusableElement(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The block being tested uses full-block fields in Zelos.
|
||||
suite('Zelos theme', function () {
|
||||
setup(function () {
|
||||
this.workspace = Blockly.inject('blocklyDiv', {
|
||||
renderer: 'zelos',
|
||||
});
|
||||
Blockly.serialization.blocks.append(this.blockJson, this.workspace);
|
||||
|
||||
// The workspace actually needs to be visible for focus.
|
||||
document.getElementById('blocklyDiv').style.visibility = 'visible';
|
||||
});
|
||||
teardown(function () {
|
||||
document.getElementById('blocklyDiv').style.visibility = 'hidden';
|
||||
workspaceTeardown.call(this, this.workspace);
|
||||
});
|
||||
|
||||
test('Opening an editor, tabbing, then editing full block field changes the second field', async function () {
|
||||
const leftInputBlock = this.workspace.getBlockById('left_input_block');
|
||||
const rightInputBlock =
|
||||
this.workspace.getBlockById('right_input_block');
|
||||
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
|
||||
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
|
||||
leftField.showEditor();
|
||||
// This must be called to avoid editor resize logic throwing an error.
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
|
||||
// Tab, then edit and close the editor.
|
||||
document.querySelector('.blocklyHtmlInput').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
}),
|
||||
);
|
||||
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
|
||||
this.simulateTypingIntoInput(rightFieldEditor, '15');
|
||||
rightFieldEditor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that only the right field changed (due to the tab).
|
||||
assert.equal(leftField.getValue(), 1);
|
||||
assert.equal(rightField.getValue(), 15);
|
||||
assert.isNull(document.querySelector('.blocklyHtmlInput'));
|
||||
});
|
||||
|
||||
test('Opening an editor, tabbing, then editing full block field changes focus to the second field', async function () {
|
||||
const leftInputBlock = this.workspace.getBlockById('left_input_block');
|
||||
const rightInputBlock =
|
||||
this.workspace.getBlockById('right_input_block');
|
||||
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
|
||||
Blockly.getFocusManager().focusNode(leftInputBlock);
|
||||
leftField.showEditor();
|
||||
// This must be called to avoid editor resize logic throwing an error.
|
||||
await Blockly.renderManagement.finishQueuedRenders();
|
||||
|
||||
// Tab, then edit and close the editor.
|
||||
document.querySelector('.blocklyHtmlInput').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
}),
|
||||
);
|
||||
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
|
||||
this.simulateTypingIntoInput(rightFieldEditor, '15');
|
||||
rightFieldEditor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that the tab causes focus to change to the right field block.
|
||||
assert.strictEqual(
|
||||
Blockly.getFocusManager().getFocusedNode(),
|
||||
rightInputBlock,
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
rightInputBlock.getFocusableElement(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,6 +249,54 @@ suite('FocusManager', function () {
|
||||
// The second register should not fail since the tree was previously unregistered.
|
||||
});
|
||||
|
||||
test('for tree with missing ID throws error', function () {
|
||||
const rootNode = this.testFocusableTree1.getRootFocusableNode();
|
||||
const rootElem = rootNode.getFocusableElement();
|
||||
const oldId = rootElem.id;
|
||||
rootElem.removeAttribute('id');
|
||||
|
||||
const errorMsgRegex =
|
||||
/Attempting to register a tree with a root element that has an invalid ID.+?/;
|
||||
assert.throws(
|
||||
() => this.focusManager.registerTree(this.testFocusableTree1),
|
||||
errorMsgRegex,
|
||||
);
|
||||
// Restore the ID for other tests.
|
||||
rootElem.id = oldId;
|
||||
});
|
||||
|
||||
test('for tree with null ID throws error', function () {
|
||||
const rootNode = this.testFocusableTree1.getRootFocusableNode();
|
||||
const rootElem = rootNode.getFocusableElement();
|
||||
const oldId = rootElem.id;
|
||||
rootElem.setAttribute('id', null);
|
||||
|
||||
const errorMsgRegex =
|
||||
/Attempting to register a tree with a root element that has an invalid ID.+?/;
|
||||
assert.throws(
|
||||
() => this.focusManager.registerTree(this.testFocusableTree1),
|
||||
errorMsgRegex,
|
||||
);
|
||||
// Restore the ID for other tests.
|
||||
rootElem.id = oldId;
|
||||
});
|
||||
|
||||
test('for tree with empty throws error', function () {
|
||||
const rootNode = this.testFocusableTree1.getRootFocusableNode();
|
||||
const rootElem = rootNode.getFocusableElement();
|
||||
const oldId = rootElem.id;
|
||||
rootElem.setAttribute('id', '');
|
||||
|
||||
const errorMsgRegex =
|
||||
/Attempting to register a tree with a root element that has an invalid ID.+?/;
|
||||
assert.throws(
|
||||
() => this.focusManager.registerTree(this.testFocusableTree1),
|
||||
errorMsgRegex,
|
||||
);
|
||||
// Restore the ID for other tests.
|
||||
rootElem.id = oldId;
|
||||
});
|
||||
|
||||
test('for unmanaged tree does not overwrite tab index', function () {
|
||||
this.focusManager.registerTree(this.testFocusableTree1, false);
|
||||
|
||||
|
||||
@@ -348,6 +348,80 @@ suite('FocusableTreeTraverser', function () {
|
||||
});
|
||||
|
||||
suite('findFocusableNodeFor()', function () {
|
||||
test('for element without ID returns null', function () {
|
||||
const tree = this.testFocusableTree1;
|
||||
const rootNode = tree.getRootFocusableNode();
|
||||
const rootElem = rootNode.getFocusableElement();
|
||||
const oldId = rootElem.id;
|
||||
// Normally it's not valid to miss an ID, but it can realistically happen.
|
||||
rootElem.removeAttribute('id');
|
||||
|
||||
const finding = FocusableTreeTraverser.findFocusableNodeFor(
|
||||
rootElem,
|
||||
tree,
|
||||
);
|
||||
// Restore the ID for other tests.
|
||||
rootElem.setAttribute('id', oldId);
|
||||
|
||||
assert.isNull(finding);
|
||||
});
|
||||
|
||||
test('for element with null ID returns null', function () {
|
||||
const tree = this.testFocusableTree1;
|
||||
const rootNode = tree.getRootFocusableNode();
|
||||
const rootElem = rootNode.getFocusableElement();
|
||||
const oldId = rootElem.id;
|
||||
// Normally it's not valid to miss an ID, but it can realistically happen.
|
||||
rootElem.setAttribute('id', null);
|
||||
|
||||
const finding = FocusableTreeTraverser.findFocusableNodeFor(
|
||||
rootElem,
|
||||
tree,
|
||||
);
|
||||
// Restore the ID for other tests.
|
||||
rootElem.setAttribute('id', oldId);
|
||||
|
||||
assert.isNull(finding);
|
||||
});
|
||||
|
||||
test('for element with null ID string returns null', function () {
|
||||
const tree = this.testFocusableTree1;
|
||||
const rootNode = tree.getRootFocusableNode();
|
||||
const rootElem = rootNode.getFocusableElement();
|
||||
const oldId = rootElem.id;
|
||||
// This is a quirky version of the null variety above that's actually
|
||||
// functionallity equivalent (since 'null' is converted to a string).
|
||||
rootElem.setAttribute('id', 'null');
|
||||
|
||||
const finding = FocusableTreeTraverser.findFocusableNodeFor(
|
||||
rootElem,
|
||||
tree,
|
||||
);
|
||||
// Restore the ID for other tests.
|
||||
rootElem.setAttribute('id', oldId);
|
||||
|
||||
assert.isNull(finding);
|
||||
});
|
||||
|
||||
test('for element with empty ID returns null', function () {
|
||||
const tree = this.testFocusableTree1;
|
||||
const rootNode = tree.getRootFocusableNode();
|
||||
const rootElem = rootNode.getFocusableElement();
|
||||
const oldId = rootElem.id;
|
||||
// An empty ID is invalid since it will potentially conflict with other
|
||||
// elements, and element IDs must be unique for focus management.
|
||||
rootElem.setAttribute('id', '');
|
||||
|
||||
const finding = FocusableTreeTraverser.findFocusableNodeFor(
|
||||
rootElem,
|
||||
tree,
|
||||
);
|
||||
// Restore the ID for other tests.
|
||||
rootElem.setAttribute('id', oldId);
|
||||
|
||||
assert.isNull(finding);
|
||||
});
|
||||
|
||||
test('for root element returns root', function () {
|
||||
const tree = this.testFocusableTree1;
|
||||
const rootNode = tree.getRootFocusableNode();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import * as Blockly from '../../build/src/core/blockly.js';
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {defineStackBlock} from './test_helpers/block_definitions.js';
|
||||
import {
|
||||
sharedTestSetup,
|
||||
@@ -399,6 +400,19 @@ suite('Keyboard Shortcut Items', function () {
|
||||
});
|
||||
});
|
||||
|
||||
suite('Paste', function () {
|
||||
test('Disabled when nothing has been copied', function () {
|
||||
const pasteShortcut =
|
||||
Blockly.ShortcutRegistry.registry.getRegistry()[
|
||||
Blockly.ShortcutItems.names.PASTE
|
||||
];
|
||||
Blockly.clipboard.setLastCopiedData(undefined);
|
||||
|
||||
const isPasteEnabled = pasteShortcut.preconditionFn();
|
||||
assert.isFalse(isPasteEnabled);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Undo', function () {
|
||||
setup(function () {
|
||||
this.undoSpy = sinon.spy(this.workspace, 'undo');
|
||||
@@ -434,13 +448,13 @@ suite('Keyboard Shortcut Items', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
// Do not undo if a gesture is in progress.
|
||||
suite('Gesture in progress', function () {
|
||||
// Do not undo if a drag is in progress.
|
||||
suite('Drag in progress', function () {
|
||||
testCases.forEach(function (testCase) {
|
||||
const testCaseName = testCase[0];
|
||||
const keyEvent = testCase[1];
|
||||
test(testCaseName, function () {
|
||||
sinon.stub(Blockly.Gesture, 'inProgress').returns(true);
|
||||
sinon.stub(this.workspace, 'isDragging').returns(true);
|
||||
this.injectionDiv.dispatchEvent(keyEvent);
|
||||
sinon.assert.notCalled(this.undoSpy);
|
||||
sinon.assert.notCalled(this.hideChaffSpy);
|
||||
@@ -494,13 +508,13 @@ suite('Keyboard Shortcut Items', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
// Do not undo if a gesture is in progress.
|
||||
suite('Gesture in progress', function () {
|
||||
// Do not redo if a drag is in progress.
|
||||
suite('Drag in progress', function () {
|
||||
testCases.forEach(function (testCase) {
|
||||
const testCaseName = testCase[0];
|
||||
const keyEvent = testCase[1];
|
||||
test(testCaseName, function () {
|
||||
sinon.stub(Blockly.Gesture, 'inProgress').returns(true);
|
||||
sinon.stub(this.workspace, 'isDragging').returns(true);
|
||||
this.injectionDiv.dispatchEvent(keyEvent);
|
||||
sinon.assert.notCalled(this.redoSpy);
|
||||
sinon.assert.notCalled(this.hideChaffSpy);
|
||||
@@ -534,8 +548,8 @@ suite('Keyboard Shortcut Items', function () {
|
||||
sinon.assert.calledWith(this.undoSpy, true);
|
||||
sinon.assert.calledOnce(this.hideChaffSpy);
|
||||
});
|
||||
test('Not called when a gesture is in progress', function () {
|
||||
sinon.stub(Blockly.Gesture, 'inProgress').returns(true);
|
||||
test('Not called when a drag is in progress', function () {
|
||||
sinon.stub(this.workspace, 'isDragging').returns(true);
|
||||
this.injectionDiv.dispatchEvent(this.ctrlYEvent);
|
||||
sinon.assert.notCalled(this.undoSpy);
|
||||
sinon.assert.notCalled(this.hideChaffSpy);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {assert} from '../../node_modules/chai/chai.js';
|
||||
import {
|
||||
assertEventFired,
|
||||
createChangeListenerSpy,
|
||||
@@ -167,5 +168,15 @@ suite('Workspace comment', function () {
|
||||
this.workspace.id,
|
||||
);
|
||||
});
|
||||
|
||||
test('focuses the workspace when deleted', function () {
|
||||
const comment = new Blockly.comments.RenderedWorkspaceComment(
|
||||
this.workspace,
|
||||
);
|
||||
Blockly.getFocusManager().focusNode(comment);
|
||||
assert.equal(Blockly.getFocusManager().getFocusedNode(), comment);
|
||||
comment.view.getCommentBarButtons()[1].performAction();
|
||||
assert.equal(Blockly.getFocusManager().getFocusedNode(), this.workspace);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user