diff --git a/main/blocklyinit.js b/main/blocklyinit.js index d0c728bb..7ba4e028 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -167,7 +167,7 @@ if (!Blockly.serialization.registry.getClass?.('flockLock')) { let workspace = null; export { workspace }; -function installWorkspaceJumpDebug(workspace) { +export function installWorkspaceJumpDebug(workspace) { if (!workspace || workspace.__jumpDebugInstalled) return; workspace.__jumpDebugInstalled = true; @@ -180,6 +180,21 @@ function installWorkspaceJumpDebug(workspace) { } }); + // Also treat a direct tap on the canvas as a field interaction, so the jump is + // suppressed on the tap that opens a field editor, not just after it closes. + let lastCanvasPointerDown = null; + const parentSvg = workspace.getParentSvg?.(); + if (parentSvg) { + parentSvg.addEventListener( + 'pointerdown', + (e) => { + if (e.target.closest?.('.blocklyFlyout, .blocklyToolboxDiv')) return; + lastCanvasPointerDown = { timestamp: performance.now() }; + }, + true + ); + } + const workspaceScroll = workspace.scroll?.bind(workspace); if (workspaceScroll) { workspace.scroll = function (...args) { @@ -193,19 +208,24 @@ function installWorkspaceJumpDebug(workspace) { const msSinceFieldEdit = lastFieldEdit ? Math.round(performance.now() - lastFieldEdit.timestamp) : null; + const msSinceCanvasPointerDown = lastCanvasPointerDown + ? Math.round(performance.now() - lastCanvasPointerDown.timestamp) + : null; const fromFocusScroll = stack.some( (line) => line.includes('scrollBoundsIntoView') || line.includes('onNodeFocus') ); const largeHorizontalJump = typeof requestedX === 'number' && Math.abs(requestedX - beforeX) > 100; - - if ( - fromFocusScroll && - typeof msSinceFieldEdit === 'number' && - msSinceFieldEdit < 1500 && - largeHorizontalJump - ) { - return; + const recentFieldInteraction = + (typeof msSinceFieldEdit === 'number' && msSinceFieldEdit < 1500) || + (typeof msSinceCanvasPointerDown === 'number' && msSinceCanvasPointerDown < 800); + + if (fromFocusScroll && recentFieldInteraction && largeHorizontalJump) { + // Keep X pinned (suppress the unwanted jump) but still apply Y — + // scrollBoundsIntoView bundles both into one call, and a wanted + // vertical correction shouldn't be dropped along with the horizontal one. + const requestedY = args[1]; + return workspaceScroll(beforeX, typeof requestedY === 'number' ? requestedY : this.scrollY); } return workspaceScroll(...args); diff --git a/tests/blocklyinit.test.js b/tests/blocklyinit.test.js new file mode 100644 index 00000000..36d8b852 --- /dev/null +++ b/tests/blocklyinit.test.js @@ -0,0 +1,89 @@ +import { expect } from 'chai'; +import * as Blockly from 'blockly'; +import { installWorkspaceJumpDebug } from '../main/blocklyinit.js'; +import { defineControlBlocks } from '../blocks/control.js'; + +export function runBlocklyInitTests(_flock) { + describe('main/blocklyinit @blocklyinit', function () { + let workspace; + let container; + + before(function () { + defineControlBlocks(); + }); + + beforeEach(function () { + container = document.createElement('div'); + container.style.width = '300px'; + container.style.height = '200px'; + document.body.appendChild(container); + workspace = Blockly.inject(container, { + move: { scrollbars: { horizontal: true, vertical: true }, drag: true, wheel: true }, + }); + + // scroll() clamps to the content bounding box (see blockly_compressed.js): + // a single block barely bigger than the viewport gives zero scroll slack, + // so the box has to be much larger than the viewport in both dimensions to + // leave real scroll range for the assertions below. + const near = workspace.newBlock('wait'); + near.initSvg(); + near.render(); + + const far = workspace.newBlock('wait'); + far.initSvg(); + far.render(); + far.moveBy(3000, 3000); + + installWorkspaceJumpDebug(workspace); + }); + + afterEach(function () { + workspace?.dispose(); + container?.remove(); + }); + + describe('focus-scroll jump suppression', function () { + // Named to match the stack-trace check in installWorkspaceJumpDebug, which + // only reacts to Blockly's own focus-follow path (scrollBoundsIntoView / + // onNodeFocus), mirroring the real call site in blockly_compressed.js. + function scrollBoundsIntoView(x, y) { + workspace.scroll(x, y); + } + + it('suppresses a large horizontal jump but keeps the accompanying vertical scroll after a direct canvas tap', function () { + workspace + .getParentSvg() + .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + + const beforeX = workspace.scrollX; + const beforeY = workspace.scrollY; + scrollBoundsIntoView(beforeX - 400, beforeY - 50); + + expect(workspace.scrollX).to.equal(beforeX); + expect(workspace.scrollY).to.equal(beforeY - 50); + }); + + it('applies both axes normally when there was no recent canvas tap', function () { + const beforeX = workspace.scrollX; + const beforeY = workspace.scrollY; + scrollBoundsIntoView(beforeX - 400, beforeY - 50); + + expect(workspace.scrollX).to.equal(beforeX - 400); + expect(workspace.scrollY).to.equal(beforeY - 50); + }); + + it('applies both axes normally for a non-focus-driven scroll even after a canvas tap', function () { + workspace + .getParentSvg() + .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + + const beforeX = workspace.scrollX; + const beforeY = workspace.scrollY; + workspace.scroll(beforeX - 400, beforeY - 50); + + expect(workspace.scrollX).to.equal(beforeX - 400); + expect(workspace.scrollY).to.equal(beforeY - 50); + }); + }); + }); +} diff --git a/tests/tests.html b/tests/tests.html index a4507a5c..82288646 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -532,6 +532,13 @@

Flock Test Example

importFn: 'runSecurityTests', pattern: '@security', }, + { + id: 'blocklyinit', + name: 'Blockly Init Tests', + importPath: './blocklyinit.test.js', + importFn: 'runBlocklyInitTests', + pattern: 'main/blocklyinit', + }, ]; import * as flockmodule from '../flock.js';