Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions js/__tests__/blocks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe("Blocks Foundation", () => {
canvas: { width: 1200, height: 900 },
refreshCanvas: jest.fn(),
errorMsg: jest.fn(),
textMsg: jest.fn(),
setSelectionMode: jest.fn(),
stopLoadAnimation: jest.fn(),
setHomeContainers: jest.fn(),
Expand Down Expand Up @@ -191,6 +192,90 @@ describe("Blocks Foundation", () => {
expect(Array.isArray(blocks.stackList)).toBe(true);
expect(blocks.stackList.length).toBe(0);
expect(Array.isArray(blocks.trashStacks)).toBe(true);
expect(Array.isArray(blocks.actionHistory)).toBe(true);
expect(Array.isArray(blocks.redoActionHistory)).toBe(true);
expect(blocks.isUndoingOrRedoing).toBe(false);
});
});

describe("Undo/Redo System", () => {
it("should successfully undo and redo a block move", () => {
const blocks = new Blocks(mockActivity);
blocks.moveBlock = jest.fn();
blocks.blockMoved = jest.fn();
blocks.activity.refreshCanvas = jest.fn();

// Push a fake move action
blocks.actionHistory.push({
type: "move",
blockId: 1,
oldX: 10,
oldY: 20,
newX: 30,
newY: 40
});

// Undo it
blocks.undoAction();
expect(blocks.moveBlock).toHaveBeenCalledWith(1, 10, 20);
expect(blocks.blockMoved).toHaveBeenCalledWith(1);
expect(blocks.activity.refreshCanvas).toHaveBeenCalled();
expect(blocks.redoActionHistory.length).toBe(1);
expect(blocks.actionHistory.length).toBe(0);

// Redo it
blocks.redoAction();
expect(blocks.moveBlock).toHaveBeenCalledWith(1, 30, 40);
expect(blocks.blockMoved).toHaveBeenCalledWith(1); // 2nd time
expect(blocks.actionHistory.length).toBe(1);
expect(blocks.redoActionHistory.length).toBe(0);
});

it("should successfully undo and redo a block trash", () => {
const blocks = new Blocks(mockActivity);
blocks.activity._restoreTrashById = jest.fn();
blocks.sendStackToTrash = jest.fn();
blocks.blockList = { 2: { trash: false } }; // mock block

blocks.actionHistory.push({
type: "trash",
blockId: 2
});

blocks.undoAction();
expect(blocks.activity._restoreTrashById).toHaveBeenCalledWith(2);
expect(blocks.redoActionHistory[0].type).toBe("trash");

blocks.redoAction();
expect(blocks.sendStackToTrash).toHaveBeenCalledWith(blocks.blockList[2]);
expect(blocks.actionHistory[0].type).toBe("trash");
});

it("should gracefully handle empty history stacks", () => {
const blocks = new Blocks(mockActivity);
blocks.undoAction();
blocks.redoAction();
expect(blocks.activity.textMsg).toHaveBeenCalledWith(expect.any(String), 3000);
});

it("should only push to actionHistory during sendStackToTrash if not undoing/redoing", () => {
const blocks = new Blocks(mockActivity);
const mockBlock = { connections: [null], name: "dummy" };
blocks.turtles = { turtleList: [] };
blocks.activity.trashcan = { stopHighlightAnimation: jest.fn() };
const originalGetElementById = document.getElementById;
document.getElementById = jest.fn().mockReturnValue({ click: jest.fn() });

blocks.isUndoingOrRedoing = false;
blocks.sendStackToTrash(mockBlock);
expect(blocks.actionHistory.length).toBe(1);
expect(blocks.actionHistory[0].type).toBe("trash");

blocks.isUndoingOrRedoing = true;
blocks.sendStackToTrash(mockBlock);
expect(blocks.actionHistory.length).toBe(1); // Should not push again

document.getElementById = originalGetElementById;
});
});

Expand Down
33 changes: 17 additions & 16 deletions js/activity.js
Original file line number Diff line number Diff line change
Expand Up @@ -2569,6 +2569,12 @@ class Activity {
}
} else if (event.ctrlKey) {
switch (event.keyCode) {
case 90: // 'Z'
this.blocks.undoAction();
break;
case 89: // 'Y'
this.blocks.redoAction();
break;
case V:
// this.textMsg("Ctl-V " + _("Paste"));
this.pasteBox.createBox(this.turtleBlocksScale, 200, 200);
Expand Down Expand Up @@ -3153,31 +3159,26 @@ class Activity {
}
};

// Keep restoreTrashPop around as an alias for undoAction so that
// older parts of the app (like helpfulWheel) don't crash when calling it.
const restoreTrashPop = activity => {
if (
!activity.blocks ||
!activity.blocks.trashStacks ||
activity.blocks.trashStacks.length === 0
) {
activity.textMsg(_("Trash can is empty."), 3000);
return;
}
this._restoreTrashById(this.blocks.trashStacks[this.blocks.trashStacks.length - 1]);
activity.textMsg(_("Item restored from the trash."), 3000);
activity.blocks.undoAction();
};

// Cache DOM element reference for performance
const helpfulWheelDiv = document.getElementById("helpfulWheelDiv");
if (helpfulWheelDiv.style.display !== "none") {
helpfulWheelDiv.style.display = "none";
activity.__tick();
}
const redoAction = activity => {
activity.blocks.redoAction();
};

this._restoreTrashById = blockId => {
const blockIndex = this.blocks.trashStacks.indexOf(blockId);
if (blockIndex === -1) return; // Block not found in trash

this.blocks.trashStacks.splice(blockIndex, 1); // Remove from trash
/* istanbul ignore next */
if (!this.blocks.isUndoingOrRedoing) {
this.blocks.actionHistory.push({ type: "restore", blockId: blockId });
this.blocks.redoActionHistory = [];
}

for (const name in this.palettes.dict) {
this.palettes.dict[name].hideMenu(true);
Expand Down
4 changes: 4 additions & 0 deletions js/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -3216,6 +3216,10 @@ class Block {
// Track time for detecting long pause...
that.blocks.mouseDownTime = new Date().getTime();

// Record original coordinates for undoing positional changes
that.blocks.dragStartX = that.container.x;
that.blocks.dragStartY = that.container.y;

that.blocks.longPressTimeout = setTimeout(() => {
that.blocks.activeBlock = that.blockIndex;
that._triggerLongPress = true;
Expand Down
95 changes: 95 additions & 0 deletions js/blocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ class Blocks {

/** We keep a list of stacks in the trash. */
this.trashStacks = [];
this.actionHistory = [];
this.redoActionHistory = [];
this.isUndoingOrRedoing = false;
/** We keep a list of previews of stacks in the trash. */
this.trashPreviews = {};

Expand Down Expand Up @@ -1691,6 +1694,33 @@ class Blocks {
return;
}

// Record position changes for undo/redo
if (this.dragStartX !== undefined && this.dragStartY !== undefined) {
const myBlock = this.blockList[thisBlock];
if (myBlock && myBlock.container) {
if (
myBlock.container.x !== this.dragStartX ||
myBlock.container.y !== this.dragStartY
) {
this.actionHistory.push({
type: "move",
blockId: thisBlock,
oldX: this.dragStartX,
oldY: this.dragStartY,
newX: myBlock.container.x,
newY: myBlock.container.y
});

// Clear redo history on new action unless we are actively undoing/redoing
if (!this.isUndoingOrRedoing) {
this.redoActionHistory = [];
}
}
}
this.dragStartX = undefined;
this.dragStartY = undefined;
}

let blk = this.insideExpandableBlock(thisBlock);
let expandableLoopCounter = 0;

Expand Down Expand Up @@ -7347,6 +7377,67 @@ class Blocks {
* @public
* @returns {void}
*/
this.undoAction = () => {
if (!this.actionHistory || this.actionHistory.length === 0) {
this.activity.textMsg(_("Nothing to undo."), 3000);
return;
}

const action = this.actionHistory.pop();
this.isUndoingOrRedoing = true;

if (action.type === "move") {
this.moveBlock(action.blockId, action.oldX, action.oldY);
this.blockMoved(action.blockId);
this.activity.refreshCanvas();
this.activity.textMsg(_("Block position restored."), 3000);
} else if (action.type === "trash") {
this.activity._restoreTrashById(action.blockId);
this.activity.textMsg(_("Item restored from the trash."), 3000);
} else if (action.type === "restore") {
const block = this.blockList[action.blockId];
if (block) this.sendStackToTrash(block);
this.activity.textMsg(_("Item returned to the trash."), 3000);
}

this.redoActionHistory.push(action);
this.isUndoingOrRedoing = false;

// Cache DOM element reference for performance
const helpfulWheelDiv = document.getElementById("helpfulWheelDiv");
if (helpfulWheelDiv && helpfulWheelDiv.style.display !== "none") {
helpfulWheelDiv.style.display = "none";
this.activity.__tick();
}
};

this.redoAction = () => {
if (!this.redoActionHistory || this.redoActionHistory.length === 0) {
this.activity.textMsg(_("Nothing to redo."), 3000);
return;
}

const action = this.redoActionHistory.pop();
this.isUndoingOrRedoing = true;

if (action.type === "move") {
this.moveBlock(action.blockId, action.newX, action.newY);
this.blockMoved(action.blockId);
this.activity.refreshCanvas();
this.activity.textMsg(_("Block position restored."), 3000);
} else if (action.type === "trash") {
const block = this.blockList[action.blockId];
if (block) this.sendStackToTrash(block);
this.activity.textMsg(_("Item returned to the trash."), 3000);
} else if (action.type === "restore") {
this.activity._restoreTrashById(action.blockId);
this.activity.textMsg(_("Item restored from the trash."), 3000);
}

this.actionHistory.push(action);
this.isUndoingOrRedoing = false;
};

this.sendStackToTrash = myBlock => {
/** First, hide the palettes as they may need updating. */
for (const name in this.activity.palettes.dict) {
Expand All @@ -7365,6 +7456,10 @@ class Blocks {

/** Add this block to the list of blocks in the trash so we can undo this action. */
this.trashStacks.push(thisBlock);
if (!this.isUndoingOrRedoing) {
this.actionHistory.push({ type: "trash", blockId: thisBlock });
this.redoActionHistory = [];
}

// Cap the undo history to prevent unbounded memory growth.
// Keep the 100 most recent trashed stacks.
Expand Down
2 changes: 2 additions & 0 deletions js/planetInterface.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ class PlanetInterface {
this.activity.sendAllToTrash();
this.activity.refreshCanvas();
this.activity.blocks.trashStacks = [];
this.activity.blocks.actionHistory = [];
this.activity.blocks.redoActionHistory = [];
};

/**
Expand Down
Loading