diff --git a/src/actions/clipboard.ts b/src/actions/clipboard.ts index d34991a7..c322bc09 100644 --- a/src/actions/clipboard.ts +++ b/src/actions/clipboard.ts @@ -20,6 +20,7 @@ import {Navigation} from '../navigation'; import {getShortActionShortcut} from '../shortcut_formatting'; import * as Blockly from 'blockly'; import {clearPasteHints, showCopiedHint, showCutHint} from '../hints'; +import {Inserter} from './inserter'; const KeyCodes = blocklyUtils.KeyCodes; const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind( @@ -46,7 +47,10 @@ export class Clipboard { /** The workspace a copy or cut keyboard shortcut happened in. */ private copyWorkspace: WorkspaceSvg | null = null; - constructor(private navigation: Navigation) {} + constructor( + private navigation: Navigation, + private inserter: Inserter, + ) {} /** * Install these actions as both keyboard shortcuts and context menu items. @@ -370,26 +374,13 @@ export class Clipboard { private pasteCallback(workspace: WorkspaceSvg) { if (!this.copyData || !this.copyWorkspace) return false; clearPasteHints(workspace); - + const copyDataLocal = this.copyData; const pasteWorkspace = this.copyWorkspace.isFlyout ? workspace : this.copyWorkspace; - const targetNode = this.navigation.getStationaryNode(pasteWorkspace); - // If we're pasting in the flyout it still targets the workspace. Focus first - // so ensure correct selection handling. - this.navigation.focusWorkspace(workspace); - - Events.setGroup(true); - const block = clipboard.paste(this.copyData, pasteWorkspace) as BlockSvg; - if (block) { - if (targetNode) { - this.navigation.tryToConnectBlock(targetNode, block); - } - Events.setGroup(false); - return true; - } - Events.setGroup(false); - return false; + return this.inserter.insert(pasteWorkspace, () => { + return clipboard.paste(copyDataLocal, pasteWorkspace) as BlockSvg; + }); } } diff --git a/src/actions/enter.ts b/src/actions/enter.ts index d8016483..8e5aaeca 100644 --- a/src/actions/enter.ts +++ b/src/actions/enter.ts @@ -4,12 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - ASTNode, - Events, - ShortcutRegistry, - utils as BlocklyUtils, -} from 'blockly/core'; +import {ASTNode, utils as BlocklyUtils, ShortcutRegistry} from 'blockly/core'; import type { Block, @@ -20,13 +15,9 @@ import type { } from 'blockly/core'; import * as Constants from '../constants'; +import {showHelpHint} from '../hints'; import type {Navigation} from '../navigation'; -import {Mover} from './mover'; -import { - showConstrainedMovementHint, - showHelpHint, - showUnconstrainedMoveHint, -} from '../hints'; +import {Inserter} from './inserter'; const KeyCodes = BlocklyUtils.KeyCodes; @@ -35,8 +26,8 @@ const KeyCodes = BlocklyUtils.KeyCodes; */ export class EnterAction { constructor( - private mover: Mover, private navigation: Navigation, + private inserter: Inserter, ) {} /** @@ -117,132 +108,13 @@ export class EnterAction { } /** - * Inserts a block from the flyout. - * Tries to find a connection on the block to connect to the marked - * location. If no connection has been marked, or there is not a compatible - * connection then the block is placed on the workspace. - * Trigger a toast per session if possible. + * Inserts a block from the flyout using the shared insertion logic. * * @param workspace The main workspace. The workspace * the block will be placed on. */ private insertFromFlyout(workspace: WorkspaceSvg) { - workspace.setResizesEnabled(false); - // Create a new event group or append to the existing group. - const existingGroup = Events.getGroup(); - if (!existingGroup) { - Events.setGroup(true); - } - - const stationaryNode = this.navigation.getStationaryNode(workspace); - const newBlock = this.createNewBlock(workspace); - if (!newBlock) return; - const insertStartPoint = stationaryNode - ? this.navigation.findInsertStartPoint(stationaryNode, newBlock) - : null; - if (workspace.getTopBlocks().includes(newBlock)) { - this.positionNewTopLevelBlock(workspace, newBlock); - } - - workspace.setResizesEnabled(true); - - this.navigation.focusWorkspace(workspace); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!); - this.mover.startMove(workspace, newBlock, insertStartPoint); - - const isStartBlock = - !newBlock.outputConnection && - !newBlock.nextConnection && - !newBlock.previousConnection; - if (isStartBlock) { - showUnconstrainedMoveHint(workspace, false); - } else { - showConstrainedMovementHint(workspace); - } - } - - /** - * Position a new top-level block to avoid overlap at the top left. - * - * Similar to `WorkspaceSvg.cleanUp()` but does not constrain itself to not - * affecting code ordering in order to use horizontal space. - * - * @param workspace The workspace. - * @param newBlock The top-level block to move to free space. - */ - private positionNewTopLevelBlock( - workspace: WorkspaceSvg, - newBlock: BlockSvg, - ) { - const initialY = 10; - const initialX = 10; - const xSpacing = 80; - - const filteredTopBlocks = workspace - .getTopBlocks(true) - .filter((block) => block.id !== newBlock.id); - const allBlockBounds = filteredTopBlocks.map((block) => - block.getBoundingRectangle(), - ); - - const toolboxWidth = workspace.getToolbox()?.getWidth(); - const workspaceWidth = - workspace.getParentSvg().clientWidth - (toolboxWidth ?? 0); - const workspaceHeight = workspace.getParentSvg().clientHeight; - const {height: newBlockHeight, width: newBlockWidth} = - newBlock.getHeightWidth(); - - const getNextIntersectingBlock = function ( - newBlockRect: BlocklyUtils.Rect, - ): BlocklyUtils.Rect | null { - for (const rect of allBlockBounds) { - if (newBlockRect.intersects(rect)) { - return rect; - } - } - return null; - }; - - let cursorY = initialY; - let cursorX = initialX; - const minBlockHeight = workspace - .getRenderer() - .getConstants().MIN_BLOCK_HEIGHT; - // Make the initial movement of shifting the block to its best possible position. - let boundingRect = newBlock.getBoundingRectangle(); - newBlock.moveBy(cursorX - boundingRect.left, cursorY - boundingRect.top, [ - 'cleanup', - ]); - newBlock.snapToGrid(); - - boundingRect = newBlock.getBoundingRectangle(); - let conflictingRect = getNextIntersectingBlock(boundingRect); - while (conflictingRect != null) { - const newCursorX = - conflictingRect.left + conflictingRect.getWidth() + xSpacing; - const newCursorY = - conflictingRect.top + conflictingRect.getHeight() + minBlockHeight; - if (newCursorX + newBlockWidth <= workspaceWidth) { - cursorX = newCursorX; - } else if (newCursorY + newBlockHeight <= workspaceHeight) { - cursorY = newCursorY; - cursorX = initialX; - } else { - // Off screen, but new blocks will be selected which will scroll them - // into view. - cursorY = newCursorY; - cursorX = initialX; - } - newBlock.moveBy(cursorX - boundingRect.left, cursorY - boundingRect.top, [ - 'cleanup', - ]); - newBlock.snapToGrid(); - boundingRect = newBlock.getBoundingRectangle(); - conflictingRect = getNextIntersectingBlock(boundingRect); - } - - newBlock.bringToFront(); + this.inserter.insert(workspace, () => this.createNewBlock(workspace)); } /** diff --git a/src/actions/inserter.ts b/src/actions/inserter.ts new file mode 100644 index 00000000..a1800fbf --- /dev/null +++ b/src/actions/inserter.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ASTNode, + utils as BlocklyUtils, + BlockSvg, + Events, + WorkspaceSvg, +} from 'blockly/core'; + +import {showConstrainedMovementHint, showUnconstrainedMoveHint} from '../hints'; +import type {Navigation} from '../navigation'; +import {Mover} from './mover'; + +/** + * Class with common insert logic. + */ +export class Inserter { + constructor( + private mover: Mover, + private navigation: Navigation, + ) {} + + /** + * Inserts a block and leaves the block in move mode. + * + * Tries to find a connection on the block to connect to the cursor + * If no connection has been marked, or there is not a compatible + * connection then the block is placed on the workspace in free space. + * + * @param workspace The main workspace. The workspace + * the block will be placed on. + * @param blockFactory Creates the new block, e.g. from flyout or clipboard. + * @return true if a block was inserted, false otherwise. + */ + insert( + workspace: WorkspaceSvg, + blockFactory: (workspace: WorkspaceSvg) => BlockSvg | null, + ): boolean { + workspace.setResizesEnabled(false); + // Create a new event group or append to the existing group. + const existingGroup = Events.getGroup(); + if (!existingGroup) { + Events.setGroup(true); + } + + const stationaryNode = this.navigation.getStationaryNode(workspace); + const newBlock = blockFactory(workspace); + if (!newBlock) return false; + const insertStartPoint = stationaryNode + ? this.navigation.findInsertStartPoint(stationaryNode, newBlock) + : null; + if (workspace.getTopBlocks().includes(newBlock)) { + this.positionNewTopLevelBlock(workspace, newBlock); + } + + workspace.setResizesEnabled(true); + + this.navigation.focusWorkspace(workspace); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!); + this.mover.startMove(workspace, newBlock, insertStartPoint); + + const isStartBlock = + !newBlock.outputConnection && + !newBlock.nextConnection && + !newBlock.previousConnection; + if (isStartBlock) { + showUnconstrainedMoveHint(workspace, false); + } else { + showConstrainedMovementHint(workspace); + } + return true; + } + + /** + * Position a new top-level block to avoid overlap at the top left. + * + * Similar to `WorkspaceSvg.cleanUp()` but does not constrain itself to not + * affecting code ordering in order to use horizontal space. + * + * @param workspace The workspace. + * @param newBlock The top-level block to move to free space. + */ + private positionNewTopLevelBlock( + workspace: WorkspaceSvg, + newBlock: BlockSvg, + ) { + const initialY = 10; + const initialX = 10; + const xSpacing = 80; + + const filteredTopBlocks = workspace + .getTopBlocks(true) + .filter((block) => block.id !== newBlock.id); + const allBlockBounds = filteredTopBlocks.map((block) => + block.getBoundingRectangle(), + ); + + const toolboxWidth = workspace.getToolbox()?.getWidth(); + const workspaceWidth = + workspace.getParentSvg().clientWidth - (toolboxWidth ?? 0); + const workspaceHeight = workspace.getParentSvg().clientHeight; + const {height: newBlockHeight, width: newBlockWidth} = + newBlock.getHeightWidth(); + + const getNextIntersectingBlock = function ( + newBlockRect: BlocklyUtils.Rect, + ): BlocklyUtils.Rect | null { + for (const rect of allBlockBounds) { + if (newBlockRect.intersects(rect)) { + return rect; + } + } + return null; + }; + + let cursorY = initialY; + let cursorX = initialX; + const minBlockHeight = workspace + .getRenderer() + .getConstants().MIN_BLOCK_HEIGHT; + // Make the initial movement of shifting the block to its best possible position. + let boundingRect = newBlock.getBoundingRectangle(); + newBlock.moveBy(cursorX - boundingRect.left, cursorY - boundingRect.top, [ + 'cleanup', + ]); + newBlock.snapToGrid(); + + boundingRect = newBlock.getBoundingRectangle(); + let conflictingRect = getNextIntersectingBlock(boundingRect); + while (conflictingRect != null) { + const newCursorX = + conflictingRect.left + conflictingRect.getWidth() + xSpacing; + const newCursorY = + conflictingRect.top + conflictingRect.getHeight() + minBlockHeight; + if (newCursorX + newBlockWidth <= workspaceWidth) { + cursorX = newCursorX; + } else if (newCursorY + newBlockHeight <= workspaceHeight) { + cursorY = newCursorY; + cursorX = initialX; + } else { + // Off screen, but new blocks will be selected which will scroll them + // into view. + cursorY = newCursorY; + cursorX = initialX; + } + newBlock.moveBy(cursorX - boundingRect.left, cursorY - boundingRect.top, [ + 'cleanup', + ]); + newBlock.snapToGrid(); + boundingRect = newBlock.getBoundingRectangle(); + conflictingRect = getNextIntersectingBlock(boundingRect); + } + + newBlock.bringToFront(); + } +} diff --git a/src/navigation.ts b/src/navigation.ts index c6929e28..cbbe29aa 100644 --- a/src/navigation.ts +++ b/src/navigation.ts @@ -1128,33 +1128,6 @@ export class Navigation { } } - /** - * Pastes the copied block to the marked location if possible or - * onto the workspace otherwise. - * - * @param copyData The data to paste into the workspace. - * @param workspace The workspace to paste the data into. - * @returns True if the paste was sucessful, false otherwise. - */ - paste(copyData: Blockly.ICopyData, workspace: Blockly.WorkspaceSvg): boolean { - // Do this before clipoard.paste due to cursor/focus workaround in getCurNode. - const targetNode = workspace.getCursor()?.getCurNode(); - - Blockly.Events.setGroup(true); - const block = Blockly.clipboard.paste( - copyData, - workspace, - ) as Blockly.BlockSvg; - if (block) { - if (targetNode) { - this.tryToConnectBlock(targetNode, block); - } - return true; - } - Blockly.Events.setGroup(false); - return false; - } - /** * Determines whether keyboard navigation should be allowed based on the * current state of the workspace. diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index 2afa4c89..044b1ca9 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -37,6 +37,7 @@ import {ActionMenu} from './actions/action_menu'; import {MoveActions} from './actions/move'; import {Mover} from './actions/mover'; import {UndoRedoAction} from './actions/undo_redo'; +import {Inserter} from './actions/inserter'; const KeyCodes = BlocklyUtils.KeyCodes; @@ -48,6 +49,8 @@ export class NavigationController { private mover = new Mover(this.navigation); + private inserter = new Inserter(this.mover, this.navigation); + shortcutDialog: ShortcutDialog = new ShortcutDialog(); /** Context menu and keyboard action for deletion. */ @@ -62,7 +65,7 @@ export class NavigationController { /** Keyboard shortcut for disconnection. */ disconnectAction: DisconnectAction = new DisconnectAction(this.navigation); - clipboard: Clipboard = new Clipboard(this.navigation); + clipboard: Clipboard = new Clipboard(this.navigation, this.inserter); workspaceMovement: WorkspaceMovement = new WorkspaceMovement(this.navigation); @@ -71,7 +74,7 @@ export class NavigationController { exitAction: ExitAction = new ExitAction(this.navigation); - enterAction: EnterAction = new EnterAction(this.mover, this.navigation); + enterAction: EnterAction = new EnterAction(this.navigation, this.inserter); undoRedoAction: UndoRedoAction = new UndoRedoAction();