Skip to content

Commit 2c71504

Browse files
committed
fix: allow dragged C blocks to wrap partial stacks
Resolves #3494
1 parent 9a1f1d0 commit 2c71504

3 files changed

Lines changed: 225 additions & 0 deletions

File tree

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import './renderer/cat/renderer'
4747
import './renderer/renderer'
4848
import { registerScratchBlockPaster } from './scratch_block_paster'
4949
import * as scratchBlocksUtils from './scratch_blocks_utils'
50+
import './scratch_c_block_wrap'
5051
import './scratch_comment_icon'
5152
import './scratch_connection_checker'
5253
import { registerScratchContinuousCategory } from './scratch_continuous_category'

src/scratch_c_block_wrap.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Copyright 2026 Scratch Foundation
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as Blockly from 'blockly/core'
6+
7+
/**
8+
* When a C-block (a block with a statement input) is dropped onto a block that
9+
* is already in the middle of a stack, Blockly's default behaviour reconnects
10+
* the displaced block to the C-block's *next* connection (i.e. after the
11+
* C-block), leaving the C-block's "mouth" empty. Scratch expects the
12+
* displaced block to be placed *inside* the C-block (wrap behaviour).
13+
*
14+
* We fix this by patching `Connection.getConnectionForOrphanedConnection` so
15+
* that it prefers an available statement-input connection on the dragging block
16+
* over the last-next-in-stack connection when the orphan is a statement block.
17+
* This applies to both real blocks and insertion markers (drag previews) so
18+
* that the preview matches the actual drop result.
19+
*/
20+
const originalGetConnectionForOrphanedConnection = Blockly.Connection.getConnectionForOrphanedConnection.bind(
21+
Blockly.Connection,
22+
)
23+
24+
Blockly.Connection.getConnectionForOrphanedConnection = function (
25+
startBlock: Blockly.Block,
26+
orphanConnection: Blockly.Connection,
27+
): Blockly.Connection | null {
28+
// Apply the wrapping logic when the orphaned connection is a statement
29+
// connection (PREVIOUS_STATEMENT). Value connections (OUTPUT_VALUE) are
30+
// already handled by the original implementation.
31+
if (orphanConnection.type === Blockly.ConnectionType.PREVIOUS_STATEMENT) {
32+
const checker = orphanConnection.getConnectionChecker()
33+
for (const input of startBlock.inputList) {
34+
const conn = input.connection
35+
// Look for an unoccupied statement input (NEXT_STATEMENT) on the
36+
// dragging block. Statement inputs are connections owned by an input
37+
// (getParentInput() !== null) rather than the block's own nextConnection.
38+
if (
39+
conn?.type === Blockly.ConnectionType.NEXT_STATEMENT &&
40+
!conn.isConnected() &&
41+
checker.canConnect(orphanConnection, conn, false)
42+
) {
43+
return conn
44+
}
45+
}
46+
}
47+
return originalGetConnectionForOrphanedConnection(startBlock, orphanConnection)
48+
}
49+
50+
/**
51+
* When a drag preview (insertion marker) is cleaned up, Blockly calls
52+
* `marker.unplug(true)` which heals the stack by reconnecting the block before
53+
* the marker to whatever block was after it (via `marker.nextConnection`).
54+
*
55+
* Because we now send the displaced block (B2) into the insertion marker's
56+
* statement input for a correct visual preview, B2 is no longer at
57+
* `marker.nextConnection` when cleanup runs. The heal step therefore skips B2
58+
* and it gets lost when the marker is disposed.
59+
*
60+
* We fix this by patching `hideInsertionMarker` to restore B2 from the
61+
* marker's statement input back to `marker.nextConnection` before the original
62+
* cleanup runs, so the standard healing logic reconnects B1.next → B2.
63+
*/
64+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- InsertionMarkerPreviewer does not publicly expose hideInsertionMarker
65+
const proto = Blockly.InsertionMarkerPreviewer.prototype as any
66+
const originalHideInsertionMarker = proto.hideInsertionMarker as (markerConn: Blockly.Connection) => void
67+
proto.hideInsertionMarker = function (this: Blockly.InsertionMarkerPreviewer, markerConn: Blockly.Connection) {
68+
const marker = markerConn.getSourceBlock() as Blockly.BlockSvg
69+
70+
// Restore a real block from a statement input to nextConnection so that
71+
// unplug(true) can heal the stack correctly. Conditions:
72+
// - marker is mid-stack (has a predecessor)
73+
// - marker.nextConnection is empty (displaced block is NOT already there)
74+
// - a non-marker block is sitting in a statement input
75+
if (marker.previousConnection?.isConnected() && marker.nextConnection && !marker.nextConnection.isConnected()) {
76+
for (const input of marker.inputList) {
77+
const conn = input.connection
78+
if (conn?.type === Blockly.ConnectionType.NEXT_STATEMENT && conn.isConnected()) {
79+
const blockInInput = conn.targetBlock() as Blockly.BlockSvg | null
80+
if (blockInInput && !blockInInput.isInsertionMarker()) {
81+
const prev = blockInInput.previousConnection
82+
if (!prev) continue
83+
prev.disconnect()
84+
marker.nextConnection.connect(prev)
85+
break
86+
}
87+
}
88+
}
89+
}
90+
91+
originalHideInsertionMarker.call(this, markerConn)
92+
}

tests/unit/c_block_wrap.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Copyright 2026 Scratch Foundation
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as Blockly from 'blockly/core'
6+
import { afterEach, assert, beforeEach, describe, expect, it } from 'vitest'
7+
// Registers the C-block wrapping fix.
8+
import '../../src/scratch_c_block_wrap'
9+
10+
let workspace: Blockly.Workspace
11+
12+
beforeEach(() => {
13+
workspace = new Blockly.Workspace()
14+
})
15+
16+
afterEach(() => {
17+
workspace.dispose()
18+
})
19+
20+
describe('C-block wrapping', () => {
21+
const BLOCK_TYPES = ['test_c_block_wrap', 'test_stmt_wrap_inner', 'test_stmt_wrap_outer']
22+
23+
beforeEach(() => {
24+
Blockly.defineBlocksWithJsonArray([
25+
{
26+
type: 'test_c_block_wrap',
27+
message0: 'repeat %1',
28+
args0: [{ type: 'input_statement', name: 'SUBSTACK' }],
29+
previousStatement: null,
30+
nextStatement: null,
31+
},
32+
{
33+
type: 'test_stmt_wrap_inner',
34+
message0: 'inner block',
35+
previousStatement: null,
36+
nextStatement: null,
37+
},
38+
{
39+
type: 'test_stmt_wrap_outer',
40+
message0: 'outer block',
41+
previousStatement: null,
42+
nextStatement: null,
43+
},
44+
])
45+
})
46+
47+
afterEach(() => {
48+
for (const type of BLOCK_TYPES) {
49+
delete Blockly.Blocks[type]
50+
}
51+
})
52+
53+
it('displaced block goes into statement input when C-block is dropped onto a middle-of-stack block (actual drop)', () => {
54+
// Stack: outerBlock → innerBlock
55+
const cBlock = workspace.newBlock('test_c_block_wrap')
56+
const outerBlock = workspace.newBlock('test_stmt_wrap_outer')
57+
const innerBlock = workspace.newBlock('test_stmt_wrap_inner')
58+
59+
// Connect outerBlock → innerBlock (innerBlock is connected inside an existing stack)
60+
assert(outerBlock.nextConnection, 'outerBlock should have nextConnection')
61+
assert(innerBlock.previousConnection, 'innerBlock should have previousConnection')
62+
outerBlock.nextConnection.connect(innerBlock.previousConnection)
63+
64+
// Simulate the scenario: the C-block's previous connects to outerBlock's next,
65+
// displacing innerBlock. Where should innerBlock (the orphan) go?
66+
//
67+
// Expected (wrap behavior): into cBlock's statement input (SUBSTACK)
68+
// Actual before fix (bug): after cBlock's next connection
69+
const result = Blockly.Connection.getConnectionForOrphanedConnection(cBlock, innerBlock.previousConnection)
70+
71+
const statementInputConn = cBlock.getInput('SUBSTACK')?.connection
72+
assert(statementInputConn, 'cBlock should have a SUBSTACK statement input with a connection')
73+
74+
expect(result).toBe(statementInputConn)
75+
})
76+
77+
it('hideInsertionMarker restores displaced block from statement input so stack heals correctly', () => {
78+
// When a C-block insertion marker is mid-stack with the displaced block (B2)
79+
// sitting in its statement input (as placed by our getConnectionForOrphanedConnection
80+
// patch), hideInsertionMarker must move B2 back to marker.nextConnection so that
81+
// marker.unplug(true) can reconnect B1 → B2 when the preview is dismissed.
82+
const b1 = workspace.newBlock('test_stmt_wrap_outer')
83+
const marker = workspace.newBlock('test_c_block_wrap')
84+
const b2 = workspace.newBlock('test_stmt_wrap_inner')
85+
86+
marker.setInsertionMarker(true)
87+
88+
// B1 → marker (marker is mid-stack)
89+
assert(b1.nextConnection, 'b1 should have nextConnection')
90+
assert(marker.previousConnection, 'marker should have previousConnection')
91+
b1.nextConnection.connect(marker.previousConnection)
92+
93+
// B2 is in the marker's statement input (placed there by our orphan-connection patch)
94+
const markerStatementConn = marker.getInput('SUBSTACK')?.connection
95+
assert(markerStatementConn, 'marker should have a SUBSTACK statement input')
96+
assert(b2.previousConnection, 'b2 should have previousConnection')
97+
markerStatementConn.connect(b2.previousConnection)
98+
99+
// The original hideInsertionMarker does not use `this`, so any object works as context.
100+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call -- tested function does not use `this`
101+
;(Blockly.InsertionMarkerPreviewer.prototype as any).hideInsertionMarker.call({}, marker.previousConnection)
102+
103+
// After cleanup, the stack should be healed: B1 → B2 (marker was disposed)
104+
expect(b1.nextConnection.targetBlock()).toBe(b2)
105+
})
106+
107+
it('displaced block goes into statement input even when C-block is an insertion marker (preview matches drop)', () => {
108+
// During drag preview, Blockly creates an insertion marker to visualise
109+
// where the dragged block will land. getConnectionForOrphanedConnection
110+
// is called with the MARKER as startBlock. We want the displaced block to
111+
// go into the marker's statement input so the preview matches the actual
112+
// drop result (B2 inside C, not after C). A separate patch in
113+
// hideInsertionMarker restores B2 to nextConnection before cleanup so that
114+
// unplug(true) can heal the stack correctly when the preview is dismissed.
115+
const markerBlock = workspace.newBlock('test_c_block_wrap')
116+
markerBlock.setInsertionMarker(true)
117+
118+
const outerBlock = workspace.newBlock('test_stmt_wrap_outer')
119+
const innerBlock = workspace.newBlock('test_stmt_wrap_inner')
120+
assert(outerBlock.nextConnection, 'outerBlock should have nextConnection')
121+
assert(innerBlock.previousConnection, 'innerBlock should have previousConnection')
122+
outerBlock.nextConnection.connect(innerBlock.previousConnection)
123+
124+
const result = Blockly.Connection.getConnectionForOrphanedConnection(markerBlock, innerBlock.previousConnection)
125+
126+
const statementInputConn = markerBlock.getInput('SUBSTACK')?.connection
127+
assert(statementInputConn, 'markerBlock should have a SUBSTACK statement input with a connection')
128+
129+
// Should redirect the orphan into the statement input (same as a real block).
130+
expect(result).toBe(statementInputConn)
131+
})
132+
})

0 commit comments

Comments
 (0)