Skip to content

Commit a76b8bb

Browse files
authored
Refactor/selection controller (#7765)
* refactor(activity): extract 2D drag-selection into SelectionController Move drag-selection and multi-selection logic from activity.js into a new js/activity/selection-controller.js module. The module exposes a clean public API via setupSelectionController(activity) and delegates all calls from activity to the new SelectionController class. No behavioral changes; drag timing, rAF throttling, and selection highlighting are preserved exactly. What was moved - _create2Ddrag, setupMouseEvents, _createDrag - drag lifecycle and document-level mouse listeners - drawSelectionArea, rectanglesOverlap, selectBlocksInDragArea, unhighlightSelectedBlocks, isEqual - selection geometry and highlighting helpers - setSelectionMode, deselectSelectedBlocks, deleteMultipleBlocks, copyMultipleBlocks, selectMode - selection mode and bulk block operations - State: isDragging, isSelecting, selectionModeOn, selectedBlocks, dragArea, startX/startY/currentX/currentY, hasMouseMoved, selectionArea, dragRect, blockRect, _dragSelectRafPending isDragging, isSelecting and hasMouseMoved are exposed as getters/ setters on the controller rather than raw mutable fields; the render loop, click handler, and toolbar mouseover handler in activity.js read them via this.selectionController.<state> instead of local fields, matching how isHelpfulSearchWidgetOn is accessed through searchController. loader.js: add "activity/selection-controller" to the activity/activity shim deps list and paths map, matching the existing controller extraction pattern. Updated activity_toolbar_integration.test.js: its sandboxed Activity constructor now needs setupSelectionController mocked alongside the other setup*Controller calls. Signed-off-by: Vanshika <pahalvanshikaa@gmail.com> * test(selection-controller): add behavioral test coverage Add a dedicated Jest suite for SelectionController covering setup and delegation stubs, selection mode toggling, mouse-driven drag initiation, selection rectangle drawing, the rectanglesOverlap and isEqual helpers, selecting blocks within a drag area (including the scrollBlockContainer x-offset), highlighting/unhighlighting selected blocks, setSelectionMode's guarded on/off transitions, deselectSelectedBlocks, deleteMultipleBlocks, copyMultipleBlocks (including the connected-block skip logic), and _create2Ddrag's mouse listener wiring, mouseup teardown, and rAF-throttled mousemove handling. Tests assert on observable state (selectionController fields, DOM style, mock call arguments) rather than internals, and specifically pin the requestAnimationFrame throttling behavior — at most one pending frame per burst of mousemove events, and the pre-existing two-frame delay before setSelectionMode flips selectionModeOn on (since selectedBlocks starts empty on the first frame after _create2Ddrag resets it). Signed-off-by: Vanshika <pahalvanshikaa@gmail.com> --------- Signed-off-by: Vanshika <pahalvanshikaa@gmail.com>
1 parent 4ce50ed commit a76b8bb

5 files changed

Lines changed: 1211 additions & 298 deletions

File tree

js/__tests__/activity_toolbar_integration.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ const loadActivityClass = () => {
106106
})),
107107
setupSearchController: jest.fn(),
108108
setupWorkspaceLayoutController: jest.fn(),
109+
setupSelectionController: jest.fn(),
109110
hideDOMLabel: jest.fn(),
110111
setupActivityRecorder: jest.fn(),
111112
setupActivityAbcParser: jest.fn(),

js/activity.js

Lines changed: 14 additions & 297 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ try {
3434
setupGridController, setupGridRenderer, setupPluginController, setupToolbarController, setupAlertController, setupAlertRenderer, setupPaletteLoader, PluginDialog,
3535
setupProjectManager,
3636
setupKeyboardController,
37-
setupSearchController, setupSearchUI, setupWorkspaceLayoutController,
37+
setupSearchController, setupSearchUI, setupWorkspaceLayoutController, setupSelectionController,
3838
setupActivityAbcParser, setupActivityIdleWatcher,
3939
COLLAPSEBLOCKSBUTTON, COLLAPSEBUTTON, createDefaultStack,
4040
createHelpContent, createjs, DATAOBJS, DEFAULTBLOCKSCALE,
@@ -355,15 +355,6 @@ class Activity {
355355
this.storage = {};
356356
}
357357

358-
// Flag to indicate whether the user is performing a 2D drag operation.
359-
this.isDragging = false;
360-
361-
// Flag to indicate whether user is selecting
362-
this.isSelecting = false;
363-
364-
// Flag to indicate the selection mode is on
365-
this.selectionModeOn = false;
366-
367358
//Flag to check if any other input box is active or not
368359
this.isInputON = false;
369360

@@ -490,6 +481,7 @@ class Activity {
490481
this.searchUI = setupSearchUI(this);
491482
setupSearchController(this, this.searchUI);
492483
setupWorkspaceLayoutController(this);
484+
setupSelectionController(this);
493485
this.pluginDialog = new PluginDialog({
494486
onLoadBuiltIn: name => this._loadBuiltInPlugin(name),
495487
onDelete: () => this._deletePlugin(),
@@ -607,7 +599,8 @@ class Activity {
607599
if (this.stage) {
608600
const hasActiveTweens = createjs.Tween.hasActiveTweens();
609601
const hasActiveGifs = this.gifAnimator && this.gifAnimator.getActiveCount() > 0;
610-
const isInteracting = this.isDragging || this.isSelecting;
602+
const isInteracting =
603+
this.selectionController.isDragging || this.selectionController.isSelecting;
611604

612605
if (this.stageDirty || hasActiveTweens || hasActiveGifs || isInteracting) {
613606
// Recompute culling when container moved.
@@ -3926,295 +3919,19 @@ class Activity {
39263919

39273920
this.__saveLocally = (...args) => this.projectManager.saveLocally(...args);
39283921

3929-
// Setup mouse events to start the drag
3930-
3931-
this.setupMouseEvents = () => {
3932-
this.addEventListener(
3933-
document,
3934-
"mousedown",
3935-
event => {
3936-
if (!this.isSelecting) return;
3937-
this.moving = false;
3938-
// event.preventDefault();
3939-
// event.stopPropagation();
3940-
if (event.target.id === "myCanvas") {
3941-
this._createDrag(event);
3942-
}
3943-
},
3944-
false
3945-
);
3946-
};
3947-
3948-
// deselect the selected blocks
3949-
3950-
this.deselectSelectedBlocks = () => {
3951-
this.unhighlightSelectedBlocks(false);
3952-
this.setSelectionMode(false);
3953-
};
3922+
// 2D drag-selection and multi-selection are owned by
3923+
// SelectionController (js/activity/selection-controller.js).
3924+
// setupSelectionController() installs the delegation stubs below
3925+
// (setupMouseEvents, deselectSelectedBlocks, deleteMultipleBlocks,
3926+
// copyMultipleBlocks, selectMode, _create2Ddrag, _createDrag,
3927+
// drawSelectionArea, rectanglesOverlap, selectBlocksInDragArea,
3928+
// unhighlightSelectedBlocks, isEqual, setSelectionMode).
39543929

39553930
// end the drag on navbar
39563931
this.addEventListener(document.getElementById("toolbars"), "mouseover", () => {
3957-
this.isDragging = false;
3932+
this.selectionController.isDragging = false;
39583933
});
39593934

3960-
this.deleteMultipleBlocks = () => {
3961-
if (this.blocks.selectionModeOn) {
3962-
const blocksArray = this.blocks.selectedBlocks;
3963-
// figure out which of the blocks in selectedBlocks are clamp blocks and nonClamp blocks.
3964-
const clampBlocks = [];
3965-
const nonClampBlocks = [];
3966-
3967-
for (let i = 0; i < blocksArray.length; i++) {
3968-
if (this.blocks.selectedBlocks[i].isClampBlock()) {
3969-
clampBlocks.push(this.blocks.selectedBlocks[i]);
3970-
} else if (this.blocks.selectedBlocks[i].isDisconnected()) {
3971-
nonClampBlocks.push(this.blocks.selectedBlocks[i]);
3972-
}
3973-
}
3974-
3975-
for (let i = 0; i < clampBlocks.length; i++) {
3976-
this.blocks.sendStackToTrash(clampBlocks[i]);
3977-
}
3978-
3979-
for (let i = 0; i < nonClampBlocks.length; i++) {
3980-
this.blocks.sendStackToTrash(nonClampBlocks[i]);
3981-
}
3982-
// set selection mode to false
3983-
this.blocks.setSelectionToActivity(false);
3984-
this.refreshCanvas();
3985-
// Cache DOM element reference for performance
3986-
document.getElementById("helpfulWheelDiv").style.display = "none";
3987-
}
3988-
};
3989-
3990-
this.copyMultipleBlocks = () => {
3991-
if (this.blocks.selectionModeOn && this.blocks.selectedBlocks.length) {
3992-
const blocksArray = this.blocks.selectedBlocks;
3993-
let pasteDx = 0,
3994-
pasteDy = 0;
3995-
const map = new Map();
3996-
for (let i = 0; i < blocksArray.length; i++) {
3997-
const idx = blocksArray[i].blockIndex;
3998-
map.set(
3999-
idx,
4000-
blocksArray[i].connections.filter(blk => blk !== null)
4001-
);
4002-
4003-
if (
4004-
blocksArray[i].connections.some(blkno => {
4005-
const a = map.get(blkno);
4006-
return a && a.some(b => b === idx);
4007-
}) ||
4008-
blocksArray[i].trash
4009-
)
4010-
continue;
4011-
4012-
this.blocks.activeBlock = idx;
4013-
this.blocks.pasteDx = pasteDx;
4014-
this.blocks.pasteDy = pasteDy;
4015-
this.blocks.prepareStackForCopy();
4016-
this.blocks.pasteStack();
4017-
pasteDx += 21;
4018-
pasteDy += 21;
4019-
}
4020-
4021-
this.setSelectionMode(false);
4022-
this.selectedBlocks = [];
4023-
this.unhighlightSelectedBlocks(false, false);
4024-
this.blocks.setSelectedBlocks(this.selectedBlocks);
4025-
this.refreshCanvas();
4026-
// Cache DOM element reference for performance
4027-
document.getElementById("helpfulWheelDiv").style.display = "none";
4028-
}
4029-
};
4030-
4031-
this.selectMode = () => {
4032-
this.moving = false;
4033-
this.isSelecting = !this.isSelecting;
4034-
this.isSelecting
4035-
? this.textMsg(_("Select is enabled."))
4036-
: this.textMsg(_("Select is disabled."));
4037-
document.getElementById("helpfulWheelDiv").style.display = "none";
4038-
};
4039-
4040-
this._create2Ddrag = () => {
4041-
this.dragArea = {};
4042-
this.selectedBlocks = [];
4043-
this.startX = 0;
4044-
this.startY = 0;
4045-
this.currentX = 0;
4046-
this.currentY = 0;
4047-
this.hasMouseMoved = false;
4048-
// rAF guard for throttling drag-select mousemove
4049-
this._dragSelectRafPending = false;
4050-
if (this.selectionArea && this.selectionArea.parentNode) {
4051-
this.selectionArea.parentNode.removeChild(this.selectionArea);
4052-
}
4053-
this.selectionArea = document.createElement("div");
4054-
document.body.appendChild(this.selectionArea);
4055-
4056-
this.setupMouseEvents();
4057-
4058-
this.addEventListener(document, "mousemove", event => {
4059-
this.hasMouseMoved = true;
4060-
if (this.isDragging && this.isSelecting) {
4061-
this.currentX = event.clientX;
4062-
this.currentY = event.clientY;
4063-
// Throttle drag-select to one update per animation frame
4064-
if (
4065-
!this._dragSelectRafPending &&
4066-
!this.blocks.isBlockMoving &&
4067-
!this.turtles.running()
4068-
) {
4069-
this._dragSelectRafPending = true;
4070-
requestAnimationFrame(() => {
4071-
this._dragSelectRafPending = false;
4072-
this.setSelectionMode(true);
4073-
this.drawSelectionArea();
4074-
this.selectedBlocks = this.selectBlocksInDragArea();
4075-
this.unhighlightSelectedBlocks(true, true);
4076-
this.blocks.setSelectedBlocks(this.selectedBlocks);
4077-
});
4078-
}
4079-
}
4080-
});
4081-
4082-
this.addEventListener(document, "mouseup", event => {
4083-
// event.preventDefault();
4084-
if (!this.isSelecting) return;
4085-
this.isDragging = false;
4086-
this.selectionArea.style.display = "none";
4087-
this.startX = 0;
4088-
this.startY = 0;
4089-
this.currentX = 0;
4090-
this.currentY = 0;
4091-
setTimeout(() => {
4092-
this.hasMouseMoved = false;
4093-
}, 100);
4094-
});
4095-
};
4096-
4097-
// Set starting points of the drag
4098-
4099-
this._createDrag = event => {
4100-
this.isDragging = true;
4101-
this.startX = event.clientX;
4102-
this.startY = event.clientY;
4103-
};
4104-
4105-
// Draw the area that has been dragged
4106-
4107-
this.drawSelectionArea = () => {
4108-
const x = Math.min(this.startX, this.currentX);
4109-
const y = Math.min(this.startY, this.currentY);
4110-
const width = Math.abs(this.currentX - this.startX);
4111-
const height = Math.abs(this.currentY - this.startY);
4112-
4113-
// Batch all CSS writes into a single cssText assignment
4114-
// to avoid multiple forced style recalculations.
4115-
this.selectionArea.style.cssText =
4116-
"display:flex;position:absolute;" +
4117-
"left:" +
4118-
x +
4119-
"px;top:" +
4120-
y +
4121-
"px;" +
4122-
"width:" +
4123-
width +
4124-
"px;height:" +
4125-
height +
4126-
"px;" +
4127-
"z-index:9989;" +
4128-
"background-color:rgba(137,207,240,0.5);" +
4129-
"pointer-events:none;";
4130-
4131-
this.dragArea = { x, y, width, height };
4132-
};
4133-
4134-
// Check if the block is overlapping the dragged area.
4135-
4136-
this.rectanglesOverlap = (rect1, rect2) => {
4137-
return (
4138-
rect1.x + rect1.width > rect2.x &&
4139-
rect1.x < rect2.x + rect2.width &&
4140-
rect1.y + rect1.height > rect2.y &&
4141-
rect1.y < rect2.y + rect2.height
4142-
);
4143-
};
4144-
4145-
// Select the blocks that overlap the dragged area.
4146-
4147-
this.selectBlocksInDragArea = (dragArea, blocks) => {
4148-
const selectedBlocks = [];
4149-
this.dragRect = this.dragArea;
4150-
4151-
this.blocks.blockList.forEach(block => {
4152-
this.blockRect = {
4153-
x: this.scrollBlockContainer
4154-
? block.container.x + this.blocksContainer.x
4155-
: block.container.x,
4156-
y: block.container.y + this.blocksContainer.y,
4157-
height: block.height,
4158-
width: block.width
4159-
};
4160-
4161-
if (this.rectanglesOverlap(this.blockRect, this.dragRect)) {
4162-
selectedBlocks.push(block);
4163-
}
4164-
});
4165-
return selectedBlocks;
4166-
};
4167-
4168-
// Unhighlight the selected blocks
4169-
4170-
this.unhighlightSelectedBlocks = (unhighlight, selectionModeOn) => {
4171-
const blockIndexMap = new Map();
4172-
for (const [index, block] of this.blocks.blockList.entries()) {
4173-
if (block) {
4174-
blockIndexMap.set(block, index);
4175-
}
4176-
}
4177-
4178-
for (let i = 0; i < this.selectedBlocks.length; i++) {
4179-
const blockIndex = blockIndexMap.get(this.selectedBlocks[i]);
4180-
if (blockIndex === undefined) {
4181-
continue;
4182-
}
4183-
4184-
if (unhighlight) {
4185-
this.blocks.unhighlightSelectedBlocks(blockIndex, true);
4186-
} else {
4187-
this.blocks.highlight(blockIndex, true);
4188-
}
4189-
}
4190-
4191-
if (!unhighlight && this.selectedBlocks.length > 0) {
4192-
this.refreshCanvas();
4193-
}
4194-
};
4195-
4196-
// Check if two blocks are the same by identity (reference equality).
4197-
4198-
this.isEqual = (obj1, obj2) => {
4199-
return obj1 === obj2;
4200-
};
4201-
4202-
this.setSelectionMode = selection => {
4203-
if (selection) {
4204-
if (!this.selectionModeOn) {
4205-
if (this.selectedBlocks.length !== 0) {
4206-
this.selectedBlocks = [];
4207-
this.selectionModeOn = selection;
4208-
this.blocks.setSelection(this.selectionModeOn);
4209-
}
4210-
}
4211-
} else {
4212-
this.selectedBlocks = [];
4213-
this.selectionModeOn = selection;
4214-
this.blocks.setSelection(this.selectionModeOn);
4215-
}
4216-
};
4217-
42183935
/*
42193936
* Inits everything. The main function.
42203937
*/
@@ -4270,8 +3987,8 @@ class Activity {
42703987
};
42713988

42723989
this.handleDocumentClick = e => {
4273-
if (!this.hasMouseMoved) {
4274-
if (this.selectionModeOn) {
3990+
if (!this.selectionController.hasMouseMoved) {
3991+
if (this.selectionController.selectionModeOn) {
42753992
this.deselectSelectedBlocks();
42763993
} else {
42773994
this._hideHelpfulSearchWidget(e);

0 commit comments

Comments
 (0)