Skip to content

Commit 9fca7b4

Browse files
committed
fix: prevent drag surface from interfering with hover targets
Override wouldDeleteDraggable to return false when a block is dragged outside the workspace bounds. When a block is dropped on a GUI element like the backpack or a sprite tile, the flyout should not delete it even if the pointer overlaps the flyout's bounding rect. Update onDrag to check out-of-bounds state before calling the base implementation, so the delete cursor reflects the current state rather than lagging one move behind. Add a CSS rule setting pointer-events:none on all children of the drag surface. Blockly core's .blocklyDragging rule sets pointer-events:auto for the grab cursor, but during a drag the gesture handler binds pointermove/pointerup on document, so the dragged block does not need to receive pointer events. Without this rule, the dragged block intercepts hover and pointer events from elements underneath, breaking CSS :hover effects (e.g. sprite tile wiggle) and drop target detection (e.g. backpack highlight).
1 parent d066763 commit 9fca7b4

4 files changed

Lines changed: 137 additions & 5 deletions

File tree

src/css.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,18 @@ const styles = `
12211221
width: 100%;
12221222
height: 100%;
12231223
}
1224+
1225+
/* Prevent children of the drag surface from intercepting pointer
1226+
events. Blockly's field elements set pointer-events:auto for
1227+
click/edit interactions, but during a drag, Blockly's gesture
1228+
handler binds pointermove/pointerup on document so the dragged
1229+
block does not need to receive pointer events. Without this rule,
1230+
the field elements steal hover and pointer events from elements
1231+
underneath the dragged block (e.g. sprite selector tiles, the
1232+
backpack drop target). */
1233+
.blocklyBlockDragSurface * {
1234+
pointer-events: none;
1235+
}
12241236
`
12251237

12261238
Blockly.Css.register(styles)

src/scratch_dragger.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@ export class ScratchDragger extends Blockly.dragging.Dragger {
5959
* @param event The event that triggered this call.
6060
* @param totalDelta The change in pointer position since the last invocation.
6161
*/
62-
onDrag(event: PointerEvent, totalDelta: Blockly.utils.Coordinate) {
63-
super.onDrag(event, totalDelta)
62+
override onDrag(event: PointerEvent, totalDelta: Blockly.utils.Coordinate) {
63+
// Update out-of-bounds state BEFORE the base onDrag so that
64+
// wouldDeleteDraggable (called by super.onDrag to set the delete
65+
// cursor) sees the current draggedOutOfBounds value.
6466
this.updateOutOfBoundsState(event)
67+
super.onDrag(event, totalDelta)
6568
}
6669

6770
/**
@@ -84,6 +87,11 @@ export class ScratchDragger extends Blockly.dragging.Dragger {
8487
* @param event The event that ended the drag.
8588
*/
8689
onDragEnd(event: PointerEvent) {
90+
// Update out-of-bounds state BEFORE any wouldDeleteDraggable checks
91+
// so the override sees the position from the pointerup event, not
92+
// the last pointermove (which could be stale if the user moved fast).
93+
this.updateOutOfBoundsState(event)
94+
8795
// When the prototype block is dragged (via its DelegateToParentDraggable
8896
// strategy), this.draggable is the prototype, but getDragRoot returns the
8997
// definition. Handle both cases for the "procedure is in use" check.
@@ -108,8 +116,6 @@ export class ScratchDragger extends Blockly.dragging.Dragger {
108116
}
109117

110118
super.onDragEnd(event)
111-
112-
this.updateOutOfBoundsState(event)
113119
if (this.draggable instanceof Blockly.BlockSvg) {
114120
const event = new BlockDragEnd(this.getDragRoot(this.draggable) as Blockly.BlockSvg, this.draggedOutOfBounds)
115121
Blockly.Events.fire(event)
@@ -129,6 +135,21 @@ export class ScratchDragger extends Blockly.dragging.Dragger {
129135
this.workspace.removeClass(BOUNDLESS_CLASS)
130136
}
131137

138+
/**
139+
* Returns whether or not the dragged item would be deleted if dropped at
140+
* the current location. When a block is dragged outside the workspace
141+
* bounds (e.g. onto the backpack or a different sprite), the GUI handles
142+
* the drop — the flyout should not delete the block even if the pointer
143+
* happens to overlap the flyout's bounding rect.
144+
* @param event The drag event that triggered this check.
145+
* @param rootDraggable The topmost item being dragged.
146+
* @returns True if the draggable would be deleted.
147+
*/
148+
override wouldDeleteDraggable(event: PointerEvent, rootDraggable: Blockly.IDraggable & Blockly.IDeletable) {
149+
if (this.draggedOutOfBounds) return false
150+
return super.wouldDeleteDraggable(event, rootDraggable)
151+
}
152+
132153
/**
133154
* Returns whether or not the dragged item should return to its starting
134155
* position.

tests/browser/procedures.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe('procedure call mutation round-trip', () => {
182182
</mutation>
183183
</block>
184184
</xml>
185-
`)
185+
`),
186186
).not.toThrow()
187187

188188
const callBlock = workspace.getAllBlocks(false).find((b) => b.type === 'procedures_call')
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Copyright 2026 Scratch Foundation
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as Blockly from 'blockly/core'
6+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import '../../src/css'
8+
import { ScratchDragger } from '../../src/scratch_dragger'
9+
10+
beforeAll(() => {
11+
Blockly.Blocks.test_block = {
12+
init(this: Blockly.Block) {
13+
this.jsonInit({
14+
message0: 'test',
15+
previousStatement: null,
16+
nextStatement: null,
17+
})
18+
},
19+
}
20+
})
21+
22+
afterAll(() => {
23+
delete Blockly.Blocks.test_block
24+
})
25+
26+
let container: HTMLElement
27+
let workspace: Blockly.WorkspaceSvg
28+
29+
beforeEach(() => {
30+
container = document.createElement('div')
31+
container.style.width = '800px'
32+
container.style.height = '600px'
33+
document.body.appendChild(container)
34+
workspace = Blockly.inject(container, {})
35+
})
36+
37+
afterEach(() => {
38+
workspace.dispose()
39+
container.remove()
40+
vi.restoreAllMocks()
41+
})
42+
43+
describe('ScratchDragger', () => {
44+
describe('wouldDeleteDraggable', () => {
45+
it('returns false when the block is outside the workspace, even over a delete area', () => {
46+
const block = workspace.newBlock('test_block')
47+
block.initSvg()
48+
block.render()
49+
50+
const dragger = new ScratchDragger(block, workspace)
51+
dragger.draggedOutOfBounds = true
52+
53+
// Mock getDragTarget to return a delete area (simulating the
54+
// flyout being at the pointer position).
55+
const fakeDragTarget = { id: 'fake-flyout', wouldDelete: () => true }
56+
vi.spyOn(workspace, 'getDragTarget').mockReturnValue(fakeDragTarget as unknown as Blockly.IDragTarget)
57+
vi.spyOn(workspace.getComponentManager(), 'hasCapability').mockReturnValue(true)
58+
59+
const event = new PointerEvent('pointerup', { clientX: 100, clientY: 100 })
60+
expect(dragger.wouldDeleteDraggable(event, block)).toBe(false)
61+
})
62+
63+
it('allows deletion when the block is inside the workspace over a delete area', () => {
64+
const block = workspace.newBlock('test_block')
65+
block.initSvg()
66+
block.render()
67+
68+
const dragger = new ScratchDragger(block, workspace)
69+
dragger.draggedOutOfBounds = false
70+
71+
const fakeDragTarget = { id: 'fake-flyout', wouldDelete: () => true }
72+
vi.spyOn(workspace, 'getDragTarget').mockReturnValue(fakeDragTarget as unknown as Blockly.IDragTarget)
73+
vi.spyOn(workspace.getComponentManager(), 'hasCapability').mockReturnValue(true)
74+
75+
const event = new PointerEvent('pointerup', { clientX: 100, clientY: 100 })
76+
expect(dragger.wouldDeleteDraggable(event, block)).toBe(true)
77+
})
78+
})
79+
80+
describe('pointer-events on drag surface', () => {
81+
it('overrides pointer-events:auto on dragged blocks', () => {
82+
const dragSurface = container.querySelector('.blocklyBlockDragSurface')
83+
expect(dragSurface).not.toBeNull()
84+
85+
// Blockly core sets pointer-events:auto on .blocklyDragging so
86+
// the grab cursor works during drags. Our CSS rule overrides
87+
// this for children of the drag surface so that pointer events
88+
// pass through to elements underneath (backpack, sprite tiles).
89+
const child = document.createElementNS('http://www.w3.org/2000/svg', 'g')
90+
child.setAttribute('class', 'blocklyDragging')
91+
const dragGroup = dragSurface?.querySelector('g')
92+
expect(dragGroup).not.toBeNull()
93+
dragGroup?.appendChild(child)
94+
95+
const style = window.getComputedStyle(child)
96+
expect(style.pointerEvents).toBe('none')
97+
})
98+
})
99+
})

0 commit comments

Comments
 (0)