diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..176a458f94e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/core/block.ts b/core/block.ts index fa00c3ab4d3..52191d63c3c 100644 --- a/core/block.ts +++ b/core/block.ts @@ -675,17 +675,6 @@ export class Block implements IASTNodeLocation { return block; } - /** - * Returns this block if it is a shadow block, or the first non-shadow parent. - * - * @internal - */ - getFirstNonShadowBlock(): this { - if (!this.isShadow()) return this; - // We can assert the parent is non-null because shadows must have parents. - return this.getParent()!.getFirstNonShadowBlock(); - } - /** * Find all the blocks that are directly nested inside this one. * Includes value and statement inputs, as well as any following statement. diff --git a/core/block_svg.ts b/core/block_svg.ts index ca2ad181a82..54a5bfaaab7 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -599,7 +599,7 @@ export class BlockSvg const menuOptions = this.generateContextMenu(); if (menuOptions && menuOptions.length) { - ContextMenu.show(e, menuOptions, this.RTL); + ContextMenu.show(e, menuOptions, this.RTL, this.workspace); ContextMenu.setCurrentBlock(this); } } diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index b5818895f59..1234d6ef8e9 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -274,7 +274,7 @@ export class RenderedWorkspaceComment ContextMenuRegistry.ScopeType.COMMENT, {comment: this}, ); - contextMenu.show(e, menuOptions, this.workspace.RTL); + contextMenu.show(e, menuOptions, this.workspace.RTL, this.workspace); } /** Snap this comment to the nearest grid point. */ diff --git a/core/contextmenu.ts b/core/contextmenu.ts index 939477b3c4f..e469c4335a1 100644 --- a/core/contextmenu.ts +++ b/core/contextmenu.ts @@ -23,6 +23,7 @@ import {Rect} from './utils/rect.js'; import * as serializationBlocks from './serialization/blocks.js'; import * as svgMath from './utils/svg_math.js'; import * as WidgetDiv from './widgetdiv.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; import * as Xml from './xml.js'; import * as common from './common.js'; @@ -62,13 +63,15 @@ let menu_: Menu | null = null; * @param e Mouse event. * @param options Array of menu options. * @param rtl True if RTL, false if LTR. + * @param workspace The workspace associated with the context menu, if any. */ export function show( e: PointerEvent, options: (ContextMenuOption | LegacyContextMenuOption)[], rtl: boolean, + workspace?: WorkspaceSvg, ) { - WidgetDiv.show(dummyOwner, rtl, dispose); + WidgetDiv.show(dummyOwner, rtl, dispose, workspace); if (!options.length) { hide(); return; diff --git a/core/css.ts b/core/css.ts index 5a44cc0b603..20c5730935e 100644 --- a/core/css.ts +++ b/core/css.ts @@ -307,6 +307,7 @@ let content = ` .blocklyMinimalBody { margin: 0; padding: 0; + height: 100%; } .blocklyHtmlInput { diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts index fb913f88215..fadba28fb69 100644 --- a/core/dragging/block_drag_strategy.ts +++ b/core/dragging/block_drag_strategy.ts @@ -55,15 +55,24 @@ export class BlockDragStrategy implements IDragStrategy { private dragging = false; + /** + * If this is a shadow block, the offset between this block and the parent + * block, to add to the drag location. In workspace units. + */ + private dragOffset = new Coordinate(0, 0); + constructor(private block: BlockSvg) { this.workspace = block.workspace; } /** Returns true if the block is currently movable. False otherwise. */ isMovable(): boolean { + if (this.block.isShadow()) { + return this.block.getParent()?.isMovable() ?? false; + } + return ( this.block.isOwnMovable() && - !this.block.isShadow() && !this.block.isDeadOrDying() && !this.workspace.options.readOnly && // We never drag blocks in the flyout, only create new blocks that are @@ -77,6 +86,11 @@ export class BlockDragStrategy implements IDragStrategy { * from any parent blocks. */ startDrag(e?: PointerEvent): void { + if (this.block.isShadow()) { + this.startDraggingShadow(e); + return; + } + this.dragging = true; if (!eventUtils.getGroup()) { eventUtils.setGroup(true); @@ -106,6 +120,22 @@ export class BlockDragStrategy implements IDragStrategy { this.workspace.getLayerManager()?.moveToDragLayer(this.block); } + /** Starts a drag on a shadow, recording the drag offset. */ + private startDraggingShadow(e?: PointerEvent) { + const parent = this.block.getParent(); + if (!parent) { + throw new Error( + 'Tried to drag a shadow block with no parent. ' + + 'Shadow blocks should always have parents.', + ); + } + this.dragOffset = Coordinate.difference( + parent.getRelativeToSurfaceXY(), + this.block.getRelativeToSurfaceXY(), + ); + parent.startDrag(e); + } + /** * Whether or not we should disconnect the block when a drag is started. * @@ -174,6 +204,11 @@ export class BlockDragStrategy implements IDragStrategy { /** Moves the block and updates any connection previews. */ drag(newLoc: Coordinate): void { + if (this.block.isShadow()) { + this.block.getParent()?.drag(Coordinate.sum(newLoc, this.dragOffset)); + return; + } + this.block.moveDuringDrag(newLoc); this.updateConnectionPreview( this.block, @@ -317,7 +352,12 @@ export class BlockDragStrategy implements IDragStrategy { * Cleans up any state at the end of the drag. Applies any pending * connections. */ - endDrag(): void { + endDrag(e?: PointerEvent): void { + if (this.block.isShadow()) { + this.block.getParent()?.endDrag(e); + return; + } + this.fireDragEndEvent(); this.fireMoveEvent(); @@ -373,6 +413,11 @@ export class BlockDragStrategy implements IDragStrategy { * including reconnecting connections. */ revertDrag(): void { + if (this.block.isShadow()) { + this.block.getParent()?.revertDrag(); + return; + } + this.startChildConn?.connect(this.block.nextConnection); if (this.startParentConn) { switch (this.startParentConn.type) { diff --git a/core/dragging/dragger.ts b/core/dragging/dragger.ts index 71cfae471e4..66d447a6fca 100644 --- a/core/dragging/dragger.ts +++ b/core/dragging/dragger.ts @@ -42,13 +42,12 @@ export class Dragger implements IDragger { */ onDrag(e: PointerEvent, totalDelta: Coordinate) { this.moveDraggable(e, totalDelta); + const root = this.getRoot(this.draggable); // Must check `wouldDelete` before calling other hooks on drag targets // since we have documented that we would do so. - if (isDeletable(this.draggable)) { - this.draggable.setDeleteStyle( - this.wouldDeleteDraggable(e, this.draggable), - ); + if (isDeletable(root)) { + root.setDeleteStyle(this.wouldDeleteDraggable(e, root)); } this.updateDragTarget(e); } @@ -56,11 +55,12 @@ export class Dragger implements IDragger { /** Updates the drag target under the pointer (if there is one). */ protected updateDragTarget(e: PointerEvent) { const newDragTarget = this.workspace.getDragTarget(e); + const root = this.getRoot(this.draggable); if (this.dragTarget !== newDragTarget) { - this.dragTarget?.onDragExit(this.draggable); - newDragTarget?.onDragEnter(this.draggable); + this.dragTarget?.onDragExit(root); + newDragTarget?.onDragEnter(root); } - newDragTarget?.onDragOver(this.draggable); + newDragTarget?.onDragOver(root); this.dragTarget = newDragTarget; } @@ -80,7 +80,7 @@ export class Dragger implements IDragger { */ protected wouldDeleteDraggable( e: PointerEvent, - draggable: IDraggable & IDeletable, + rootDraggable: IDraggable & IDeletable, ) { const dragTarget = this.workspace.getDragTarget(e); if (!dragTarget) return false; @@ -92,50 +92,56 @@ export class Dragger implements IDragger { ); if (!isDeleteArea) return false; - return (dragTarget as IDeleteArea).wouldDelete(draggable); + return (dragTarget as IDeleteArea).wouldDelete(rootDraggable); } /** Handles any drag cleanup. */ onDragEnd(e: PointerEvent) { const origGroup = eventUtils.getGroup(); const dragTarget = this.workspace.getDragTarget(e); + const root = this.getRoot(this.draggable); + if (dragTarget) { - this.dragTarget?.onDrop(this.draggable); + this.dragTarget?.onDrop(root); } - if (this.shouldReturnToStart(e, this.draggable)) { + if (this.shouldReturnToStart(e, root)) { this.draggable.revertDrag(); } - const wouldDelete = - isDeletable(this.draggable) && - this.wouldDeleteDraggable(e, this.draggable); + const wouldDelete = isDeletable(root) && this.wouldDeleteDraggable(e, root); // TODO(#8148): use a generalized API instead of an instanceof check. if (wouldDelete && this.draggable instanceof BlockSvg) { - blockAnimations.disposeUiEffect(this.draggable); + blockAnimations.disposeUiEffect(this.draggable.getRootBlock()); } this.draggable.endDrag(e); - if (wouldDelete && isDeletable(this.draggable)) { + if (wouldDelete && isDeletable(root)) { // We want to make sure the delete gets grouped with any possible // move event. const newGroup = eventUtils.getGroup(); eventUtils.setGroup(origGroup); - this.draggable.dispose(); + root.dispose(); eventUtils.setGroup(newGroup); } } + // We need to special case blocks for now so that we look at the root block + // instead of the one actually being dragged in most cases. + private getRoot(draggable: IDraggable): IDraggable { + return draggable instanceof BlockSvg ? draggable.getRootBlock() : draggable; + } + /** * Returns true if we should return the draggable to its original location * at the end of the drag. */ - protected shouldReturnToStart(e: PointerEvent, draggable: IDraggable) { + protected shouldReturnToStart(e: PointerEvent, rootDraggable: IDraggable) { const dragTarget = this.workspace.getDragTarget(e); if (!dragTarget) return false; - return dragTarget.shouldPreventMove(draggable); + return dragTarget.shouldPreventMove(rootDraggable); } protected pixelsToWorkspaceUnits(pixelCoord: Coordinate): Coordinate { diff --git a/core/field_input.ts b/core/field_input.ts index 5c26f42b384..85431cc5b33 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -372,7 +372,12 @@ export abstract class FieldInput extends Field< if (!block) { throw new UnattachedFieldError(); } - WidgetDiv.show(this, block.RTL, this.widgetDispose_.bind(this)); + WidgetDiv.show( + this, + block.RTL, + this.widgetDispose_.bind(this), + this.workspace_, + ); this.htmlInput_ = this.widgetCreate_() as HTMLInputElement; this.isBeingEdited_ = true; this.valueWhenEditorWasOpened_ = this.value_; @@ -390,7 +395,7 @@ export abstract class FieldInput extends Field< * * @returns The newly created text input editor. */ - protected widgetCreate_(): HTMLElement { + protected widgetCreate_(): HTMLInputElement | HTMLTextAreaElement { const block = this.getSourceBlock(); if (!block) { throw new UnattachedFieldError(); @@ -546,17 +551,17 @@ export abstract class FieldInput extends Field< */ protected onHtmlInputKeyDown_(e: KeyboardEvent) { if (e.key === 'Enter') { - WidgetDiv.hide(); + WidgetDiv.hideIfOwner(this); dropDownDiv.hideWithoutAnimation(); } else if (e.key === 'Escape') { this.setValue( this.htmlInput_!.getAttribute('data-untyped-default-value'), false, ); - WidgetDiv.hide(); + WidgetDiv.hideIfOwner(this); dropDownDiv.hideWithoutAnimation(); } else if (e.key === 'Tab') { - WidgetDiv.hide(); + WidgetDiv.hideIfOwner(this); dropDownDiv.hideWithoutAnimation(); (this.sourceBlock_ as BlockSvg).tab(this, !e.shiftKey); e.preventDefault(); diff --git a/core/flyout_base.ts b/core/flyout_base.ts index f9e6545a957..18f84480c5d 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -516,6 +516,15 @@ export abstract class Flyout this.hide(); } + /** + * Get the target workspace inside the flyout. + * + * @returns The target workspace inside the flyout. + */ + getTargetWorkspace(): WorkspaceSvg { + return this.targetWorkspace; + } + /** * Is the flyout visible? * diff --git a/core/flyout_button.ts b/core/flyout_button.ts index 5b20d24da0e..e73403d77a0 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -66,14 +66,14 @@ export class FlyoutButton implements IASTNodeLocationSvg { * @param workspace The workspace in which to place this button. * @param targetWorkspace The flyout's target workspace. * @param json The JSON specifying the label/button. - * @param isLabel_ Whether this button should be styled as a label. + * @param isFlyoutLabel Whether this button should be styled as a label. * @internal */ constructor( private readonly workspace: WorkspaceSvg, private readonly targetWorkspace: WorkspaceSvg, json: toolbox.ButtonOrLabelInfo, - private readonly isLabel_: boolean, + private readonly isFlyoutLabel: boolean, ) { this.text = json['text']; @@ -100,7 +100,9 @@ export class FlyoutButton implements IASTNodeLocationSvg { * @returns The button's SVG group. */ createDom(): SVGElement { - let cssClass = this.isLabel_ ? 'blocklyFlyoutLabel' : 'blocklyFlyoutButton'; + let cssClass = this.isFlyoutLabel + ? 'blocklyFlyoutLabel' + : 'blocklyFlyoutButton'; if (this.cssClass) { cssClass += ' ' + this.cssClass; } @@ -112,7 +114,7 @@ export class FlyoutButton implements IASTNodeLocationSvg { ); let shadow; - if (!this.isLabel_) { + if (!this.isFlyoutLabel) { // Shadow rectangle (light source does not mirror in RTL). shadow = dom.createSvgElement( Svg.RECT, @@ -130,7 +132,7 @@ export class FlyoutButton implements IASTNodeLocationSvg { const rect = dom.createSvgElement( Svg.RECT, { - 'class': this.isLabel_ + 'class': this.isFlyoutLabel ? 'blocklyFlyoutLabelBackground' : 'blocklyFlyoutButtonBackground', 'rx': FlyoutButton.BORDER_RADIUS, @@ -142,7 +144,7 @@ export class FlyoutButton implements IASTNodeLocationSvg { const svgText = dom.createSvgElement( Svg.TEXT, { - 'class': this.isLabel_ ? 'blocklyFlyoutLabelText' : 'blocklyText', + 'class': this.isFlyoutLabel ? 'blocklyFlyoutLabelText' : 'blocklyText', 'x': 0, 'y': 0, 'text-anchor': 'middle', @@ -155,7 +157,7 @@ export class FlyoutButton implements IASTNodeLocationSvg { text += '\u200F'; } svgText.textContent = text; - if (this.isLabel_) { + if (this.isFlyoutLabel) { this.svgText = svgText; this.workspace .getThemeManager() @@ -179,7 +181,7 @@ export class FlyoutButton implements IASTNodeLocationSvg { ); this.height = fontMetrics.height; - if (!this.isLabel_) { + if (!this.isFlyoutLabel) { this.width += 2 * FlyoutButton.TEXT_MARGIN_X; this.height += 2 * FlyoutButton.TEXT_MARGIN_Y; shadow?.setAttribute('width', String(this.width)); @@ -235,7 +237,7 @@ export class FlyoutButton implements IASTNodeLocationSvg { /** @returns Whether or not the button is a label. */ isLabel(): boolean { - return this.isLabel_; + return this.isFlyoutLabel; } /** @@ -321,19 +323,19 @@ export class FlyoutButton implements IASTNodeLocationSvg { gesture.cancel(); } - if (this.isLabel_ && this.callbackKey) { + if (this.isFlyoutLabel && this.callbackKey) { console.warn( 'Labels should not have callbacks. Label text: ' + this.text, ); } else if ( - !this.isLabel_ && + !this.isFlyoutLabel && !( this.callbackKey && this.targetWorkspace.getButtonCallback(this.callbackKey) ) ) { console.warn('Buttons should have callbacks. Button text: ' + this.text); - } else if (!this.isLabel_) { + } else if (!this.isFlyoutLabel) { const callback = this.targetWorkspace.getButtonCallback(this.callbackKey); if (callback) { callback(this); diff --git a/core/flyout_horizontal.ts b/core/flyout_horizontal.ts index 9c9490b227f..6e77636e86b 100644 --- a/core/flyout_horizontal.ts +++ b/core/flyout_horizontal.ts @@ -240,7 +240,7 @@ export class HorizontalFlyout extends Flyout { this.workspace_.scrollbar?.setX(pos); // When the flyout moves from a wheel event, hide WidgetDiv and // dropDownDiv. - WidgetDiv.hide(); + WidgetDiv.hideIfOwnerIsInWorkspace(this.workspace_); dropDownDiv.hideWithoutAnimation(); } // Don't scroll the page. @@ -267,29 +267,35 @@ export class HorizontalFlyout extends Flyout { for (let i = 0, item; (item = contents[i]); i++) { if (item.type === 'block') { const block = item.block; - const allBlocks = block!.getDescendants(false); + + if (block === undefined || block === null) { + continue; + } + + const allBlocks = block.getDescendants(false); + for (let j = 0, child; (child = allBlocks[j]); j++) { // Mark blocks as being inside a flyout. This is used to detect and // prevent the closure of the flyout if the user right-clicks on such // a block. child.isInFlyout = true; } - const root = block!.getSvgRoot(); - const blockHW = block!.getHeightWidth(); + const root = block.getSvgRoot(); + const blockHW = block.getHeightWidth(); // Figure out where to place the block. - const tab = block!.outputConnection ? this.tabWidth_ : 0; + const tab = block.outputConnection ? this.tabWidth_ : 0; let moveX; if (this.RTL) { moveX = cursorX + blockHW.width; } else { moveX = cursorX - tab; } - block!.moveBy(moveX, cursorY); + block.moveBy(moveX, cursorY); - const rect = this.createRect_(block!, moveX, cursorY, blockHW, i); + const rect = this.createRect_(block, moveX, cursorY, blockHW, i); cursorX += blockHW.width + gaps[i]; - this.addBlockListeners_(root, block!, rect); + this.addBlockListeners_(root, block, rect); } else if (item.type === 'button') { const button = item.button as FlyoutButton; this.initFlyoutButton_(button, cursorX, cursorY); @@ -306,7 +312,6 @@ export class HorizontalFlyout extends Flyout { * @param currentDragDeltaXY How far the pointer has moved from the position * at mouse down, in pixel units. * @returns True if the drag is toward the workspace. - * @internal */ override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean { const dx = currentDragDeltaXY.x; diff --git a/core/flyout_vertical.ts b/core/flyout_vertical.ts index c9ce4f6593d..59682a390d2 100644 --- a/core/flyout_vertical.ts +++ b/core/flyout_vertical.ts @@ -209,7 +209,7 @@ export class VerticalFlyout extends Flyout { this.workspace_.scrollbar?.setY(pos); // When the flyout moves from a wheel event, hide WidgetDiv and // dropDownDiv. - WidgetDiv.hide(); + WidgetDiv.hideIfOwnerIsInWorkspace(this.workspace_); dropDownDiv.hideWithoutAnimation(); } // Don't scroll the page. @@ -233,29 +233,32 @@ export class VerticalFlyout extends Flyout { for (let i = 0, item; (item = contents[i]); i++) { if (item.type === 'block') { const block = item.block; - const allBlocks = block!.getDescendants(false); + if (!block) { + continue; + } + const allBlocks = block.getDescendants(false); for (let j = 0, child; (child = allBlocks[j]); j++) { // Mark blocks as being inside a flyout. This is used to detect and // prevent the closure of the flyout if the user right-clicks on such // a block. child.isInFlyout = true; } - const root = block!.getSvgRoot(); - const blockHW = block!.getHeightWidth(); - const moveX = block!.outputConnection + const root = block.getSvgRoot(); + const blockHW = block.getHeightWidth(); + const moveX = block.outputConnection ? cursorX - this.tabWidth_ : cursorX; - block!.moveBy(moveX, cursorY); + block.moveBy(moveX, cursorY); const rect = this.createRect_( - block!, + block, this.RTL ? moveX - blockHW.width : moveX, cursorY, blockHW, i, ); - this.addBlockListeners_(root, block!, rect); + this.addBlockListeners_(root, block, rect); cursorY += blockHW.height + gaps[i]; } else if (item.type === 'button') { @@ -274,7 +277,6 @@ export class VerticalFlyout extends Flyout { * @param currentDragDeltaXY How far the pointer has moved from the position * at mouse down, in pixel units. * @returns True if the drag is toward the workspace. - * @internal */ override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean { const dx = currentDragDeltaXY.x; diff --git a/core/gesture.ts b/core/gesture.ts index 46938d24f14..7970ed00689 100644 --- a/core/gesture.ts +++ b/core/gesture.ts @@ -1015,7 +1015,7 @@ export class Gesture { // If the gesture already went through a bubble, don't set the start block. if (!this.startBlock && !this.startBubble) { this.startBlock = block; - common.setSelected(this.startBlock.getFirstNonShadowBlock()); + common.setSelected(this.startBlock); if (block.isInFlyout && block !== block.getRootBlock()) { this.setTargetBlock(block.getRootBlock()); } else { diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index b5abf5554ee..6a7e29d26b2 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -17,6 +17,8 @@ import {KeyCodes} from './utils/keycodes.js'; import type {WorkspaceSvg} from './workspace_svg.js'; import {isDraggable} from './interfaces/i_draggable.js'; import * as eventUtils from './events/utils.js'; +import {Coordinate} from './utils/coordinate.js'; +import {Rect} from './utils/rect.js'; /** * Object holding the names of the default shortcut items. @@ -63,7 +65,8 @@ export function registerDelete() { !workspace.options.readOnly && selected != null && isDeletable(selected) && - selected.isDeletable() + selected.isDeletable() && + !Gesture.inProgress() ); }, callback(workspace, e) { @@ -72,10 +75,6 @@ export function registerDelete() { // Do this first to prevent an error in the delete code from resulting in // data loss. e.preventDefault(); - // Don't delete while dragging. Jeez. - if (Gesture.inProgress()) { - return false; - } const selected = common.getSelected(); if (selected instanceof BlockSvg) { selected.checkAndDelete(); @@ -93,6 +92,7 @@ export function registerDelete() { let copyData: ICopyData | null = null; let copyWorkspace: WorkspaceSvg | null = null; +let copyCoords: Coordinate | null = null; /** * Keyboard shortcut to copy a block on ctrl+c, cmd+c, or alt+c. @@ -132,6 +132,9 @@ export function registerCopy() { if (!selected || !isCopyable(selected)) return false; copyData = selected.toCopyData(); copyWorkspace = workspace; + copyCoords = isDraggable(selected) + ? selected.getRelativeToSurfaceXY() + : null; return !!copyData; }, keyCodes: [ctrlC, altC, metaC], @@ -174,6 +177,7 @@ export function registerCut() { if (selected instanceof BlockSvg) { copyData = selected.toCopyData(); copyWorkspace = workspace; + copyCoords = selected.getRelativeToSurfaceXY(); selected.checkAndDelete(); return true; } else if ( @@ -183,6 +187,9 @@ export function registerCut() { ) { copyData = selected.toCopyData(); copyWorkspace = workspace; + copyCoords = isDraggable(selected) + ? selected.getRelativeToSurfaceXY() + : null; selected.dispose(); return true; } @@ -215,7 +222,26 @@ export function registerPaste() { }, callback() { if (!copyData || !copyWorkspace) return false; - return !!clipboard.paste(copyData, copyWorkspace); + if (!copyCoords) { + // If we don't have location data about the original copyable, let the + // paster determine position. + return !!clipboard.paste(copyData, copyWorkspace); + } + + const {left, top, width, height} = copyWorkspace + .getMetricsManager() + .getViewMetrics(true); + const viewportRect = new Rect(top, top + height, left, left + width); + + if (viewportRect.contains(copyCoords.x, copyCoords.y)) { + // If the original copyable is inside the viewport, let the paster + // determine position. + return !!clipboard.paste(copyData, copyWorkspace); + } + + // Otherwise, paste in the middle of the viewport. + const centerCoords = new Coordinate(left + width / 2, top + height / 2); + return !!clipboard.paste(copyData, copyWorkspace, centerCoords); }, keyCodes: [ctrlV, altV, metaV], }; diff --git a/core/widgetdiv.ts b/core/widgetdiv.ts index e03cf0b2588..9f58bb1c544 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -8,7 +8,7 @@ import * as common from './common.js'; import * as dom from './utils/dom.js'; -import type {Field} from './field.js'; +import {Field} from './field.js'; import type {Rect} from './utils/rect.js'; import type {Size} from './utils/size.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -16,6 +16,9 @@ import type {WorkspaceSvg} from './workspace_svg.js'; /** The object currently using this container. */ let owner: unknown = null; +/** The workspace associated with the owner currently using this container. */ +let ownerWorkspace: WorkspaceSvg | null = null; + /** Optional cleanup function set by whichever object uses the widget. */ let dispose: (() => void) | null = null; @@ -76,8 +79,14 @@ export function createDom() { * @param rtl Right-to-left (true) or left-to-right (false). * @param newDispose Optional cleanup function to be run when the widget is * closed. + * @param workspace The workspace associated with the widget owner. */ -export function show(newOwner: unknown, rtl: boolean, newDispose: () => void) { +export function show( + newOwner: unknown, + rtl: boolean, + newDispose: () => void, + workspace?: WorkspaceSvg | null, +) { hide(); owner = newOwner; dispose = newDispose; @@ -85,9 +94,16 @@ export function show(newOwner: unknown, rtl: boolean, newDispose: () => void) { if (!div) return; div.style.direction = rtl ? 'rtl' : 'ltr'; div.style.display = 'block'; - const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg; - rendererClassName = mainWorkspace.getRenderer().getClassName(); - themeClassName = mainWorkspace.getTheme().getClassName(); + if (!workspace && newOwner instanceof Field) { + // For backward compatibility with plugin fields that do not provide a + // workspace to this function, attempt to derive it from the field. + workspace = (newOwner as Field).getSourceBlock()?.workspace as WorkspaceSvg; + } + ownerWorkspace = workspace ?? null; + const rendererWorkspace = + workspace ?? (common.getMainWorkspace() as WorkspaceSvg); + rendererClassName = rendererWorkspace.getRenderer().getClassName(); + themeClassName = rendererWorkspace.getTheme().getClassName(); if (rendererClassName) { dom.addClass(div, rendererClassName); } @@ -145,6 +161,19 @@ export function hideIfOwner(oldOwner: unknown) { hide(); } } + +/** + * Destroy the widget and hide the div if it is being used by an object in the + * specified workspace, or if it is used by an unknown workspace. + * + * @param oldOwnerWorkspace The workspace that was using this container. + */ +export function hideIfOwnerIsInWorkspace(oldOwnerWorkspace: WorkspaceSvg) { + if (ownerWorkspace === null || ownerWorkspace === oldOwnerWorkspace) { + hide(); + } +} + /** * Set the widget div's position and height. This function does nothing clever: * it will not ensure that your widget div ends up in the visible window. diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 14cc1101f47..aad748105f0 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -1686,7 +1686,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { this.configureContextMenu(menuOptions, e); } - ContextMenu.show(e, menuOptions, this.RTL); + ContextMenu.show(e, menuOptions, this.RTL, this); } /** @@ -2039,7 +2039,6 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * * @param x Target X to scroll to. * @param y Target Y to scroll to. - * @internal */ scroll(x: number, y: number) { this.hideChaff(/* opt_onlyClosePopups= */ true); @@ -2376,7 +2375,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { */ hideChaff(onlyClosePopups = false) { Tooltip.hide(); - WidgetDiv.hide(); + WidgetDiv.hideIfOwnerIsInWorkspace(this); dropDownDiv.hideWithoutAnimation(); this.hideComponents(onlyClosePopups); diff --git a/package-lock.json b/package-lock.json index 02335eb531d..f17ef841e42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockly", - "version": "11.0.0", + "version": "11.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockly", - "version": "11.0.0", + "version": "11.1.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index a0b50d5c67c..f7f7f124a35 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "blockly", - "version": "11.0.0", + "version": "11.1.0", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" ], "repository": { "type": "git", - "url": "https://github.com/google/blockly.git" + "url": "git+https://github.com/google/blockly.git" }, "bugs": { "url": "https://github.com/google/blockly/issues" diff --git a/scripts/gulpfiles/build_tasks.js b/scripts/gulpfiles/build_tasks.js index d7b118f325c..27a02cf30cd 100644 --- a/scripts/gulpfiles/build_tasks.js +++ b/scripts/gulpfiles/build_tasks.js @@ -677,12 +677,13 @@ async function buildLangfileShims() { // its named exports. const cjsPath = `./${lang}.js`; const wrapperPath = path.join(RELEASE_DIR, 'msg', `${lang}.mjs`); + const safeLang = lang.replace(/-/g, '_'); await fsPromises.writeFile(wrapperPath, - `import ${lang} from '${cjsPath}'; + `import ${safeLang} from '${cjsPath}'; export const { ${exportedNames.map((name) => ` ${name},`).join('\n')} -} = ${lang}; +} = ${safeLang}; `); })); } diff --git a/tests/browser/test/field_edits_test.js b/tests/browser/test/field_edits_test.js index e1699fc2d77..bad2e10eda0 100644 --- a/tests/browser/test/field_edits_test.js +++ b/tests/browser/test/field_edits_test.js @@ -14,7 +14,7 @@ const { testFileLocations, dragBlockTypeFromFlyout, screenDirection, - PAUSE_TIME, + clickWorkspace, } = require('./test_setup'); const {Key} = require('webdriverio'); @@ -48,9 +48,7 @@ async function testFieldEdits(browser, direction) { await browser.keys(['1093']); // Click on the workspace to exit the field editor - const workspace = await browser.$('#blocklyDiv > div > svg.blocklySvg > g'); - await workspace.click(); - await browser.pause(PAUSE_TIME); + await clickWorkspace(browser); const fieldValue = await browser.execute((id) => { return Blockly.getMainWorkspace() diff --git a/tests/browser/test/test_setup.js b/tests/browser/test/test_setup.js index 25b357c0716..a07b274e4d1 100644 --- a/tests/browser/test/test_setup.js +++ b/tests/browser/test/test_setup.js @@ -198,6 +198,35 @@ async function clickBlock(browser, block, clickOptions) { }, findableId); } +/** + * Clicks on the svg root of the main workspace. + * @param browser The active WebdriverIO Browser object. + * @return A Promise that resolves when the actions are completed. + */ +async function clickWorkspace(browser) { + const workspace = await browser.$('#blocklyDiv > div > svg.blocklySvg > g'); + await workspace.click(); + await browser.pause(PAUSE_TIME); +} + +/** + * Clicks on the svg root of the first mutator workspace found. + * @param browser The active WebdriverIO Browser object. + * @return A Promise that resolves when the actions are completed. + * @throws If the mutator workspace cannot be found. + */ +async function clickMutatorWorkspace(browser) { + const hasMutator = await browser.$('.blocklyMutatorBackground'); + if (!hasMutator) { + throw new Error('No mutator workspace found'); + } + const workspace = await browser + .$('.blocklyMutatorBackground') + .closest('g.blocklyWorkspace'); + await workspace.click(); + await browser.pause(PAUSE_TIME); +} + /** * @param browser The active WebdriverIO Browser object. * @param categoryName The name of the toolbox category to find. @@ -549,6 +578,8 @@ module.exports = { getSelectedBlockId, getBlockElementById, clickBlock, + clickWorkspace, + clickMutatorWorkspace, getCategory, getNthBlockOfCategory, getBlockTypeFromCategory,