diff --git a/src/actions/drag_mover.ts b/src/actions/drag_mover.ts new file mode 100644 index 00000000..9846dab4 --- /dev/null +++ b/src/actions/drag_mover.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Constants from '../constants'; +import { + ASTNode, + Connection, + ShortcutRegistry, + WorkspaceSvg, + common, + registry, + utils, +} from 'blockly'; +import type {Block, BlockSvg, IDragger} from 'blockly'; +import {Mover, MoveInfo} from './mover'; +import {Navigation} from '../navigation'; + +/** + * An experimental implementation of Mover that uses a dragger to + * perform unconstraind movment. + */ +export class DragMover extends Mover { + /** + * The distance to move an item, in workspace coordinates, when + * making an unconstrained move. + */ + UNCONSTRAINED_MOVE_DISTANCE = 20; + + /** + * Map of moves in progress. + * + * An entry for a given workspace in this map means that the this + * Mover is moving a block on that workspace, and will disable + * normal cursor movement until the move is complete. + */ + protected declare moves: Map; + + /** + * Start moving the currently-focused item on workspace, if + * possible. + * + * Should only be called if canMove has returned true. + * + * @param workspace The workspace we might be moving on. + * @returns True iff a move has successfully begun. + */ + override startMove(workspace: WorkspaceSvg) { + const cursor = workspace?.getCursor(); + const curNode = cursor?.getCurNode(); + const block = curNode?.getSourceBlock() as BlockSvg | null; + if (!cursor || !block) throw new Error('precondition failure'); + + // Select and focus block. + common.setSelected(block); + cursor.setCurNode(ASTNode.createBlockNode(block)!); + // Begin dragging block. + const DraggerClass = registry.getClassFromOptions( + registry.Type.BLOCK_DRAGGER, + workspace.options, + true, + ); + if (!DraggerClass) throw new Error('no Dragger registered'); + const dragger = new DraggerClass(block, workspace); + // Record that a move is in progress and start dragging. + const info = new DragMoveInfo(block, dragger); + this.moves.set(workspace, info); + // Begin drag. + dragger.onDragStart(info.fakePointerEvent('pointerdown')); + return true; + } + + /** + * Finish moving the currently-focused item on workspace. + * + * Should only be called if isMoving has returned true. + * + * @param workspace The workspace on which we are moving. + * @returns True iff move successfully finished. + */ + override finishMove(workspace: WorkspaceSvg) { + if (!workspace) return false; + const info = this.moves.get(workspace); + if (!info) throw new Error('no move info for workspace'); + + info.dragger.onDragEnd( + info.fakePointerEvent('pointerup'), + new utils.Coordinate(0, 0), + ); + + this.moves.delete(workspace); + return true; + } + + /** + * Abort moving the currently-focused item on workspace. + * + * Should only be called if isMoving has returned true. + * + * @param workspace The workspace on which we are moving. + * @returns True iff move successfully aborted. + */ + override abortMove(workspace: WorkspaceSvg) { + if (!workspace) return false; + const info = this.moves.get(workspace); + if (!info) throw new Error('no move info for workspace'); + + // Monkey patch dragger to trigger call to draggable.revertDrag. + (info.dragger as any).shouldReturnToStart = () => true; + info.dragger.onDragEnd( + info.fakePointerEvent('pointerup'), + new utils.Coordinate(0, 0), + ); + + this.moves.delete(workspace); + return true; + } + + /** + * Action to move the item being moved in the given direction, + * constrained to valid attachment points (if any). + * + * @param workspace The workspace to move on. + * @returns True iff this action applies and has been performed. + */ + override moveConstrained( + workspace: WorkspaceSvg, + /* ... */ + ) { + // Not yet implemented. Absorb keystroke to avoid moving cursor. + alert(`Constrained movement not implemented. + +Use ctrl+arrow or alt+arrow (option+arrow on macOS) for unconstrained move. +Use enter to complete the move, or escape to abort.`); + return true; + } + + /** + * Action to move the item being moved in the given direction, + * without constraint. + * + * @param workspace The workspace to move on. + * @param xDirection -1 to move left. 1 to move right. + * @param yDirection -1 to move up. 1 to move down. + * @returns True iff this action applies and has been performed. + */ + override moveUnconstrained( + workspace: WorkspaceSvg, + xDirection: number, + yDirection: number, + ): boolean { + if (!workspace) return false; + const info = this.moves.get(workspace); + if (!info) throw new Error('no move info for workspace'); + + info.totalDelta.x += + xDirection * this.UNCONSTRAINED_MOVE_DISTANCE * workspace.scale; + info.totalDelta.y += + yDirection * this.UNCONSTRAINED_MOVE_DISTANCE * workspace.scale; + + info.dragger.onDrag(info.fakePointerEvent('pointermove'), info.totalDelta); + return true; + } +} + +/** + * Information about the currently in-progress move for a given + * Workspace. + */ +class DragMoveInfo extends MoveInfo { + /** Total distance moved, in screen pixels */ + totalDelta = new utils.Coordinate(0, 0); + + constructor( + public readonly block: Block, + public readonly dragger: IDragger, + ) { + super(block); + } + + /** Create fake pointer event for dragging. */ + fakePointerEvent(type: string): PointerEvent { + const workspace = this.block.workspace; + if (!(workspace instanceof WorkspaceSvg)) throw new TypeError(); + + const blockCoords = utils.svgMath.wsToScreenCoordinates( + workspace, + new utils.Coordinate( + this.startLocation.x + this.totalDelta.x, + this.startLocation.y + this.totalDelta.y, + ), + ); + return new PointerEvent(type, { + clientX: blockCoords.x, + clientY: blockCoords.y, + }); + } + +} diff --git a/src/actions/mover.ts b/src/actions/mover.ts new file mode 100644 index 00000000..88c83560 --- /dev/null +++ b/src/actions/mover.ts @@ -0,0 +1,339 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Constants from '../constants'; +import { + ASTNode, + Connection, + ContextMenuRegistry, + ShortcutRegistry, + common, + utils, +} from 'blockly'; +import type {Block, BlockSvg, WorkspaceSvg} from 'blockly'; +import {Navigation} from '../navigation'; + +const KeyCodes = utils.KeyCodes; +const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind( + ShortcutRegistry.registry, +); + +/** + * Actions for moving blocks with keyboard shortcuts. + */ +export class Mover { + /** + * Function provided by the navigation controller to say whether editing + * is allowed. + */ + protected canCurrentlyEdit: (ws: WorkspaceSvg) => boolean; + + /** + * Map of moves in progress. + * + * An entry for a given workspace in this map means that the this + * Mover is moving a block on that workspace, and will disable + * normal cursor movement until the move is complete. + */ + protected moves: Map = new Map(); + + constructor( + protected navigation: Navigation, + canEdit: (ws: WorkspaceSvg) => boolean, + ) { + this.canCurrentlyEdit = canEdit; + } + + private shortcuts: ShortcutRegistry.KeyboardShortcut[] = [ + // Begin and end move. + { + name: 'Start move', + preconditionFn: (workspace) => this.canMove(workspace), + callback: (workspace) => this.startMove(workspace), + keyCodes: [KeyCodes.M], + allowCollision: true, // TODO: remove once #309 has been merged. + }, + { + name: 'Finish move', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.finishMove(workspace), + keyCodes: [KeyCodes.ENTER], + allowCollision: true, + }, + { + name: 'Abort move', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.abortMove(workspace), + keyCodes: [KeyCodes.ESC], + allowCollision: true, + }, + + // Constrained moves. + { + name: 'Move left, constrained', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.moveConstrained(workspace /* , ...*/), + keyCodes: [KeyCodes.LEFT], + allowCollision: true, + }, + { + name: 'Move right unconstraind', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.moveConstrained(workspace /* , ... */), + keyCodes: [KeyCodes.RIGHT], + allowCollision: true, + }, + { + name: 'Move up, constrained', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.moveConstrained(workspace /* , ... */), + keyCodes: [KeyCodes.UP], + allowCollision: true, + }, + { + name: 'Move down constrained', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.moveConstrained(workspace /* , ... */), + keyCodes: [KeyCodes.DOWN], + allowCollision: true, + }, + + // Unconstrained moves. + { + name: 'Move left, unconstrained', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.moveUnconstrained(workspace, -1, 0), + keyCodes: [ + createSerializedKey(KeyCodes.LEFT, [KeyCodes.ALT]), + createSerializedKey(KeyCodes.LEFT, [KeyCodes.CTRL]), + ], + }, + { + name: 'Move right, unconstraind', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.moveUnconstrained(workspace, 1, 0), + keyCodes: [ + createSerializedKey(KeyCodes.RIGHT, [KeyCodes.ALT]), + createSerializedKey(KeyCodes.RIGHT, [KeyCodes.CTRL]), + ], + }, + { + name: 'Move up unconstrained', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.moveUnconstrained(workspace, 0, -1), + keyCodes: [ + createSerializedKey(KeyCodes.UP, [KeyCodes.ALT]), + createSerializedKey(KeyCodes.UP, [KeyCodes.CTRL]), + ], + }, + { + name: 'Move down, unconstrained', + preconditionFn: (workspace) => this.isMoving(workspace), + callback: (workspace) => this.moveUnconstrained(workspace, 0, 1), + keyCodes: [ + createSerializedKey(KeyCodes.DOWN, [KeyCodes.ALT]), + createSerializedKey(KeyCodes.DOWN, [KeyCodes.CTRL]), + ], + }, + ]; + + menuItems: ContextMenuRegistry.RegistryItem[] = [ + { + displayText: 'Move Block (M)', + preconditionFn: (scope) => { + const workspace = scope.block?.workspace as WorkspaceSvg | null; + if (!workspace) return 'hidden'; + return this.canMove(workspace, scope.block) ? 'enabled' : 'disabled'; + }, + callback: (scope) => { + const workspace = scope.block?.workspace as WorkspaceSvg | null; + if (!workspace) return false; + this.startMove(workspace, scope.block); + }, + scopeType: ContextMenuRegistry.ScopeType.BLOCK, + id: 'move', + weight: 8.5, + }, + ]; + + /** + * Install the actions as both keyboard shortcuts and (where + * applicable) context menu items. + */ + install() { + for (const shortcut of this.shortcuts) { + ShortcutRegistry.registry.register(shortcut); + } + for (const menuItem of this.menuItems) { + ContextMenuRegistry.registry.register(menuItem); + } + } + + /** + * Uninstall these actions. + */ + uninstall() { + for (const shortcut of this.shortcuts) { + ShortcutRegistry.registry.unregister(shortcut.name); + } + for (const menuItem of this.menuItems) { + ContextMenuRegistry.registry.unregister(menuItem.id); + } + } + + /** + * Returns true iff we are able to begin moving the given block (or, + * if no block supplied, the block wich currently has focus) on the + * given workspace. + * + * @param workspace The workspace to move on. + * @returns True iff we can beign a move. + */ + canMove(workspace: WorkspaceSvg, block?: Block) { + if (!block) { + const cursor = workspace?.getCursor(); + const curNode = cursor?.getCurNode(); + block = curNode?.getSourceBlock() ?? undefined; + } + + return !!( + this.navigation.getState(workspace) === Constants.STATE.WORKSPACE && + this.canCurrentlyEdit(workspace) && + !this.moves.has(workspace) && // No move in progress. + block?.isMovable() + ); + } + + /** + * Returns true iff we are currently moving a block on the given + * workspace. + * + * @param workspace The workspace we might be moving on. + * @returns True iff we are moving. + */ + isMoving(workspace: WorkspaceSvg) { + return this.canCurrentlyEdit(workspace) && this.moves.has(workspace); + } + + /** + * Start moving the currently-focused item on workspace, if + * possible. + * + * Should only be called if canMove has returned true. + * + * @param workspace The workspace we might be moving on. + * @returns True iff a move has successfully begun. + */ + startMove(workspace: WorkspaceSvg, block?: Block) { + const cursor = workspace?.getCursor(); + if (!block) { + const curNode = cursor?.getCurNode(); + block = curNode?.getSourceBlock() ?? undefined; + } + if (!cursor || !block) throw new Error('precondition failure'); + + // Select and focus block. + common.setSelected(block as BlockSvg); + cursor.setCurNode(ASTNode.createBlockNode(block)!); + + // Additional implementation goes here. + console.log('startMove'); + + this.moves.set(workspace, new MoveInfo(block)); + return true; + } + + /** + * Finish moving the currently-focused item on workspace. + * + * Should only be called if isMoving has returned true. + * + * @param workspace The workspace on which we are moving. + * @returns True iff move successfully finished. + */ + finishMove(workspace: WorkspaceSvg) { + if (!workspace) return false; + const info = this.moves.get(workspace); + if (!info) throw new Error('no move info for workspace'); + + // Additional implementation goes here. + console.log('finishMove'); + + this.moves.delete(workspace); + return true; + } + + /** + * Abort moving the currently-focused item on workspace. + * + * Should only be called if isMoving has returned true. + * + * @param workspace The workspace on which we are moving. + * @returns True iff move successfully aborted. + */ + abortMove(workspace: WorkspaceSvg) { + if (!workspace) return false; + const info = this.moves.get(workspace); + if (!info) throw new Error('no move info for workspace'); + + // Additional implementation goes here. + console.log('abortMove'); + + this.moves.delete(workspace); + return true; + } + + /** + * Action to move the item being moved in the given direction, + * constrained to valid attachment points (if any). + * + * @param workspace The workspace to move on. + * @returns True iff this action applies and has been performed. + */ + moveConstrained( + workspace: WorkspaceSvg, + /* ... */ + ) { + console.log('moveConstrained'); + // Not yet implemented. Absorb keystroke to avoid moving cursor. + return true; + } + + /** + * Action to move the item being moved in the given direction, + * without constraint. + * + * @param workspace The workspace to move on. + * @param xDirection -1 to move left. 1 to move right. + * @param yDirection -1 to move up. 1 to move down. + * @returns True iff this action applies and has been performed. + */ + moveUnconstrained( + workspace: WorkspaceSvg, + xDirection: number, + yDirection: number, + ): boolean { + console.log('moveUnconstrained'); + // Not yet implemented. Absorb keystroke to avoid moving cursor. + return true; + } +} + +/** + * Information about the currently in-progress move for a given + * Workspace. + */ +export class MoveInfo { + public readonly parentNext: Connection | null; + public readonly parentInput: Connection | null; + public readonly startLocation: utils.Coordinate; + + constructor(public readonly block: Block) { + this.parentNext = block.previousConnection?.targetConnection ?? null; + this.parentInput = block.outputConnection?.targetConnection ?? null; + this.startLocation = block.getRelativeToSurfaceXY(); + } +} diff --git a/src/actions/ws_movement.ts b/src/actions/ws_movement.ts index 6286d6a2..0c2c430e 100644 --- a/src/actions/ws_movement.ts +++ b/src/actions/ws_movement.ts @@ -55,7 +55,7 @@ export class WorkspaceMovement { callback: (workspace) => this.moveWSCursor(workspace, 0, -1), keyCodes: [createSerializedKey(KeyCodes.W, [KeyCodes.SHIFT])], }, - + /** Move the cursor on the workspace down. */ { name: Constants.SHORTCUT_NAMES.MOVE_WS_CURSOR_DOWN, diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index 81a50e57..245f571d 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -34,6 +34,7 @@ import {ExitAction} from './actions/exit'; import {EnterAction} from './actions/enter'; import {DisconnectAction} from './actions/disconnect'; import {ActionMenu} from './actions/action_menu'; +import {DragMover as Mover} from './actions/drag_mover'; const KeyCodes = BlocklyUtils.KeyCodes; const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind( @@ -99,8 +100,12 @@ export class NavigationController { this.canCurrentlyNavigate.bind(this), ); + mover = new Mover(this.navigation, this.canCurrentlyEdit.bind(this)); + navigationFocus: NAVIGATION_FOCUS_MODE = NAVIGATION_FOCUS_MODE.NONE; + hasNavigationFocus: boolean = false; + /** * Original Toolbox.prototype.onShortcut method, saved by * addShortcutHandlers. @@ -504,6 +509,7 @@ export class NavigationController { this.actionMenu.install(); this.clipboard.install(); + this.mover.install(); this.shortcutDialog.install(); // Initialize the shortcut modal with available shortcuts. Needs @@ -516,10 +522,7 @@ export class NavigationController { * Removes all the keyboard navigation shortcuts. */ dispose() { - for (const shortcut of Object.values(this.shortcuts)) { - ShortcutRegistry.registry.unregister(shortcut.name); - } - + this.mover.install(); this.deleteAction.uninstall(); this.insertAction.uninstall(); this.disconnectAction.uninstall(); @@ -530,6 +533,9 @@ export class NavigationController { this.actionMenu.uninstall(); this.shortcutDialog.uninstall(); + for (const shortcut of Object.values(this.shortcuts)) { + ShortcutRegistry.registry.unregister(shortcut.name); + } this.removeShortcutHandlers(); this.navigation.dispose(); }