Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 9 additions & 18 deletions src/actions/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
ContextMenuRegistry,
Gesture,
ShortcutRegistry,
Events,

Check warning on line 11 in src/actions/clipboard.ts

View workflow job for this annotation

GitHub Actions / Eslint check

'Events' is defined but never used
utils as blocklyUtils,
clipboard,
ICopyData,
Expand All @@ -20,6 +20,7 @@
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(
Expand All @@ -46,7 +47,10 @@
/** 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.
Expand Down Expand Up @@ -370,26 +374,13 @@
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;
});
}
}
140 changes: 6 additions & 134 deletions src/actions/enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -35,8 +26,8 @@ const KeyCodes = BlocklyUtils.KeyCodes;
*/
export class EnterAction {
constructor(
private mover: Mover,
private navigation: Navigation,
private inserter: Inserter,
) {}

/**
Expand Down Expand Up @@ -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));
}

/**
Expand Down
162 changes: 162 additions & 0 deletions src/actions/inserter.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading