Skip to content

Commit 188ce8a

Browse files
feat: paste in move mode
Share insert code with flyout insert. Closes #478
1 parent 25fe2ba commit 188ce8a

File tree

5 files changed

+182
-181
lines changed

5 files changed

+182
-181
lines changed

src/actions/clipboard.ts

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {Navigation} from '../navigation';
2020
import {getShortActionShortcut} from '../shortcut_formatting';
2121
import * as Blockly from 'blockly';
2222
import {clearPasteHints, showCopiedHint, showCutHint} from '../hints';
23+
import {Inserter} from './inserter';
2324

2425
const KeyCodes = blocklyUtils.KeyCodes;
2526
const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
@@ -46,7 +47,10 @@ export class Clipboard {
4647
/** The workspace a copy or cut keyboard shortcut happened in. */
4748
private copyWorkspace: WorkspaceSvg | null = null;
4849

49-
constructor(private navigation: Navigation) {}
50+
constructor(
51+
private navigation: Navigation,
52+
private inserter: Inserter,
53+
) {}
5054

5155
/**
5256
* Install these actions as both keyboard shortcuts and context menu items.
@@ -370,26 +374,13 @@ export class Clipboard {
370374
private pasteCallback(workspace: WorkspaceSvg) {
371375
if (!this.copyData || !this.copyWorkspace) return false;
372376
clearPasteHints(workspace);
373-
377+
const copyDataLocal = this.copyData;
374378
const pasteWorkspace = this.copyWorkspace.isFlyout
375379
? workspace
376380
: this.copyWorkspace;
377381

378-
const targetNode = this.navigation.getStationaryNode(pasteWorkspace);
379-
// If we're pasting in the flyout it still targets the workspace. Focus first
380-
// so ensure correct selection handling.
381-
this.navigation.focusWorkspace(workspace);
382-
383-
Events.setGroup(true);
384-
const block = clipboard.paste(this.copyData, pasteWorkspace) as BlockSvg;
385-
if (block) {
386-
if (targetNode) {
387-
this.navigation.tryToConnectBlock(targetNode, block);
388-
}
389-
Events.setGroup(false);
390-
return true;
391-
}
392-
Events.setGroup(false);
393-
return false;
382+
return this.inserter.insert(pasteWorkspace, () => {
383+
return clipboard.paste(copyDataLocal, pasteWorkspace) as BlockSvg;
384+
});
394385
}
395386
}

src/actions/enter.ts

Lines changed: 6 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import {
8-
ASTNode,
9-
Events,
10-
ShortcutRegistry,
11-
utils as BlocklyUtils,
12-
} from 'blockly/core';
7+
import {ASTNode, utils as BlocklyUtils, ShortcutRegistry} from 'blockly/core';
138

149
import type {
1510
Block,
@@ -20,13 +15,9 @@ import type {
2015
} from 'blockly/core';
2116

2217
import * as Constants from '../constants';
18+
import {showHelpHint} from '../hints';
2319
import type {Navigation} from '../navigation';
24-
import {Mover} from './mover';
25-
import {
26-
showConstrainedMovementHint,
27-
showHelpHint,
28-
showUnconstrainedMoveHint,
29-
} from '../hints';
20+
import {Inserter} from './inserter';
3021

3122
const KeyCodes = BlocklyUtils.KeyCodes;
3223

@@ -35,8 +26,8 @@ const KeyCodes = BlocklyUtils.KeyCodes;
3526
*/
3627
export class EnterAction {
3728
constructor(
38-
private mover: Mover,
3929
private navigation: Navigation,
30+
private inserter: Inserter,
4031
) {}
4132

4233
/**
@@ -117,132 +108,13 @@ export class EnterAction {
117108
}
118109

119110
/**
120-
* Inserts a block from the flyout.
121-
* Tries to find a connection on the block to connect to the marked
122-
* location. If no connection has been marked, or there is not a compatible
123-
* connection then the block is placed on the workspace.
124-
* Trigger a toast per session if possible.
111+
* Inserts a block from the flyout using the shared insertion logic.
125112
*
126113
* @param workspace The main workspace. The workspace
127114
* the block will be placed on.
128115
*/
129116
private insertFromFlyout(workspace: WorkspaceSvg) {
130-
workspace.setResizesEnabled(false);
131-
// Create a new event group or append to the existing group.
132-
const existingGroup = Events.getGroup();
133-
if (!existingGroup) {
134-
Events.setGroup(true);
135-
}
136-
137-
const stationaryNode = this.navigation.getStationaryNode(workspace);
138-
const newBlock = this.createNewBlock(workspace);
139-
if (!newBlock) return;
140-
const insertStartPoint = stationaryNode
141-
? this.navigation.findInsertStartPoint(stationaryNode, newBlock)
142-
: null;
143-
if (workspace.getTopBlocks().includes(newBlock)) {
144-
this.positionNewTopLevelBlock(workspace, newBlock);
145-
}
146-
147-
workspace.setResizesEnabled(true);
148-
149-
this.navigation.focusWorkspace(workspace);
150-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
151-
workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!);
152-
this.mover.startMove(workspace, newBlock, insertStartPoint);
153-
154-
const isStartBlock =
155-
!newBlock.outputConnection &&
156-
!newBlock.nextConnection &&
157-
!newBlock.previousConnection;
158-
if (isStartBlock) {
159-
showUnconstrainedMoveHint(workspace, false);
160-
} else {
161-
showConstrainedMovementHint(workspace);
162-
}
163-
}
164-
165-
/**
166-
* Position a new top-level block to avoid overlap at the top left.
167-
*
168-
* Similar to `WorkspaceSvg.cleanUp()` but does not constrain itself to not
169-
* affecting code ordering in order to use horizontal space.
170-
*
171-
* @param workspace The workspace.
172-
* @param newBlock The top-level block to move to free space.
173-
*/
174-
private positionNewTopLevelBlock(
175-
workspace: WorkspaceSvg,
176-
newBlock: BlockSvg,
177-
) {
178-
const initialY = 10;
179-
const initialX = 10;
180-
const xSpacing = 80;
181-
182-
const filteredTopBlocks = workspace
183-
.getTopBlocks(true)
184-
.filter((block) => block.id !== newBlock.id);
185-
const allBlockBounds = filteredTopBlocks.map((block) =>
186-
block.getBoundingRectangle(),
187-
);
188-
189-
const toolboxWidth = workspace.getToolbox()?.getWidth();
190-
const workspaceWidth =
191-
workspace.getParentSvg().clientWidth - (toolboxWidth ?? 0);
192-
const workspaceHeight = workspace.getParentSvg().clientHeight;
193-
const {height: newBlockHeight, width: newBlockWidth} =
194-
newBlock.getHeightWidth();
195-
196-
const getNextIntersectingBlock = function (
197-
newBlockRect: BlocklyUtils.Rect,
198-
): BlocklyUtils.Rect | null {
199-
for (const rect of allBlockBounds) {
200-
if (newBlockRect.intersects(rect)) {
201-
return rect;
202-
}
203-
}
204-
return null;
205-
};
206-
207-
let cursorY = initialY;
208-
let cursorX = initialX;
209-
const minBlockHeight = workspace
210-
.getRenderer()
211-
.getConstants().MIN_BLOCK_HEIGHT;
212-
// Make the initial movement of shifting the block to its best possible position.
213-
let boundingRect = newBlock.getBoundingRectangle();
214-
newBlock.moveBy(cursorX - boundingRect.left, cursorY - boundingRect.top, [
215-
'cleanup',
216-
]);
217-
newBlock.snapToGrid();
218-
219-
boundingRect = newBlock.getBoundingRectangle();
220-
let conflictingRect = getNextIntersectingBlock(boundingRect);
221-
while (conflictingRect != null) {
222-
const newCursorX =
223-
conflictingRect.left + conflictingRect.getWidth() + xSpacing;
224-
const newCursorY =
225-
conflictingRect.top + conflictingRect.getHeight() + minBlockHeight;
226-
if (newCursorX + newBlockWidth <= workspaceWidth) {
227-
cursorX = newCursorX;
228-
} else if (newCursorY + newBlockHeight <= workspaceHeight) {
229-
cursorY = newCursorY;
230-
cursorX = initialX;
231-
} else {
232-
// Off screen, but new blocks will be selected which will scroll them
233-
// into view.
234-
cursorY = newCursorY;
235-
cursorX = initialX;
236-
}
237-
newBlock.moveBy(cursorX - boundingRect.left, cursorY - boundingRect.top, [
238-
'cleanup',
239-
]);
240-
newBlock.snapToGrid();
241-
boundingRect = newBlock.getBoundingRectangle();
242-
conflictingRect = getNextIntersectingBlock(boundingRect);
243-
}
244-
245-
newBlock.bringToFront();
117+
this.inserter.insert(workspace, () => this.createNewBlock(workspace));
246118
}
247119

248120
/**

src/actions/inserter.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
ASTNode,
9+
utils as BlocklyUtils,
10+
BlockSvg,
11+
Events,
12+
WorkspaceSvg,
13+
} from 'blockly/core';
14+
15+
import {showConstrainedMovementHint, showUnconstrainedMoveHint} from '../hints';
16+
import type {Navigation} from '../navigation';
17+
import {Mover} from './mover';
18+
19+
/**
20+
* Class with common insert logic.
21+
*/
22+
export class Inserter {
23+
constructor(
24+
private mover: Mover,
25+
private navigation: Navigation,
26+
) {}
27+
28+
/**
29+
* Inserts a block and leaves the block in move mode.
30+
*
31+
* Tries to find a connection on the block to connect to the cursor
32+
* If no connection has been marked, or there is not a compatible
33+
* connection then the block is placed on the workspace in free space.
34+
*
35+
* @param workspace The main workspace. The workspace
36+
* the block will be placed on.
37+
* @param blockFactory Creates the new block, e.g. from flyout or clipboard.
38+
* @return true if a block was inserted, false otherwise.
39+
*/
40+
insert(
41+
workspace: WorkspaceSvg,
42+
blockFactory: (workspace: WorkspaceSvg) => BlockSvg | null,
43+
): boolean {
44+
workspace.setResizesEnabled(false);
45+
// Create a new event group or append to the existing group.
46+
const existingGroup = Events.getGroup();
47+
if (!existingGroup) {
48+
Events.setGroup(true);
49+
}
50+
51+
const stationaryNode = this.navigation.getStationaryNode(workspace);
52+
const newBlock = blockFactory(workspace);
53+
if (!newBlock) return false;
54+
const insertStartPoint = stationaryNode
55+
? this.navigation.findInsertStartPoint(stationaryNode, newBlock)
56+
: null;
57+
if (workspace.getTopBlocks().includes(newBlock)) {
58+
this.positionNewTopLevelBlock(workspace, newBlock);
59+
}
60+
61+
workspace.setResizesEnabled(true);
62+
63+
this.navigation.focusWorkspace(workspace);
64+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
65+
workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!);
66+
this.mover.startMove(workspace, newBlock, insertStartPoint);
67+
68+
const isStartBlock =
69+
!newBlock.outputConnection &&
70+
!newBlock.nextConnection &&
71+
!newBlock.previousConnection;
72+
if (isStartBlock) {
73+
showUnconstrainedMoveHint(workspace, false);
74+
} else {
75+
showConstrainedMovementHint(workspace);
76+
}
77+
return true;
78+
}
79+
80+
/**
81+
* Position a new top-level block to avoid overlap at the top left.
82+
*
83+
* Similar to `WorkspaceSvg.cleanUp()` but does not constrain itself to not
84+
* affecting code ordering in order to use horizontal space.
85+
*
86+
* @param workspace The workspace.
87+
* @param newBlock The top-level block to move to free space.
88+
*/
89+
private positionNewTopLevelBlock(
90+
workspace: WorkspaceSvg,
91+
newBlock: BlockSvg,
92+
) {
93+
const initialY = 10;
94+
const initialX = 10;
95+
const xSpacing = 80;
96+
97+
const filteredTopBlocks = workspace
98+
.getTopBlocks(true)
99+
.filter((block) => block.id !== newBlock.id);
100+
const allBlockBounds = filteredTopBlocks.map((block) =>
101+
block.getBoundingRectangle(),
102+
);
103+
104+
const toolboxWidth = workspace.getToolbox()?.getWidth();
105+
const workspaceWidth =
106+
workspace.getParentSvg().clientWidth - (toolboxWidth ?? 0);
107+
const workspaceHeight = workspace.getParentSvg().clientHeight;
108+
const {height: newBlockHeight, width: newBlockWidth} =
109+
newBlock.getHeightWidth();
110+
111+
const getNextIntersectingBlock = function (
112+
newBlockRect: BlocklyUtils.Rect,
113+
): BlocklyUtils.Rect | null {
114+
for (const rect of allBlockBounds) {
115+
if (newBlockRect.intersects(rect)) {
116+
return rect;
117+
}
118+
}
119+
return null;
120+
};
121+
122+
let cursorY = initialY;
123+
let cursorX = initialX;
124+
const minBlockHeight = workspace
125+
.getRenderer()
126+
.getConstants().MIN_BLOCK_HEIGHT;
127+
// Make the initial movement of shifting the block to its best possible position.
128+
let boundingRect = newBlock.getBoundingRectangle();
129+
newBlock.moveBy(cursorX - boundingRect.left, cursorY - boundingRect.top, [
130+
'cleanup',
131+
]);
132+
newBlock.snapToGrid();
133+
134+
boundingRect = newBlock.getBoundingRectangle();
135+
let conflictingRect = getNextIntersectingBlock(boundingRect);
136+
while (conflictingRect != null) {
137+
const newCursorX =
138+
conflictingRect.left + conflictingRect.getWidth() + xSpacing;
139+
const newCursorY =
140+
conflictingRect.top + conflictingRect.getHeight() + minBlockHeight;
141+
if (newCursorX + newBlockWidth <= workspaceWidth) {
142+
cursorX = newCursorX;
143+
} else if (newCursorY + newBlockHeight <= workspaceHeight) {
144+
cursorY = newCursorY;
145+
cursorX = initialX;
146+
} else {
147+
// Off screen, but new blocks will be selected which will scroll them
148+
// into view.
149+
cursorY = newCursorY;
150+
cursorX = initialX;
151+
}
152+
newBlock.moveBy(cursorX - boundingRect.left, cursorY - boundingRect.top, [
153+
'cleanup',
154+
]);
155+
newBlock.snapToGrid();
156+
boundingRect = newBlock.getBoundingRectangle();
157+
conflictingRect = getNextIntersectingBlock(boundingRect);
158+
}
159+
160+
newBlock.bringToFront();
161+
}
162+
}

0 commit comments

Comments
 (0)