feat(editor): Add undo/redo support for canvas actions (#4787)

*  Added history store and mixin
*  Implemented node position change undo/redo
*  Implemented move nodes bulk command
*  Not clearing the redo stack after pushing the bulk command
* 🔨 Implemented commands using classes
* 🔥 Removed unnecessary interfaces and actions
* 🔥 Removing unused constants
* 🔨 Refactoring classes file
*  Adding eventBus to command obects
*  Added undo/redo support for adding and removing nodes
*  Implemented initial add/remove connections undo support
*  Covering some corner cases with reconnecting nodes
*  Adding undo support for reconnecting nodes
*  Fixing going back and forward between undo and redo
*  Implemented async command revert
*  Preventing push to undo if bulk redo/undo is in progress
*  Handling re-connecting nodes and stopped pushing empty bulk actions to undo stack
*  Handling adding a node between two connected nodes
*  Handling the case of removing multiple connections on the same index. Adding debounce to undo/redo keyboard calls
*  Removing unnecessary timeouts, adding missing awaits, refactoring
*  Resetting history when opening new workflow, fixing incorrect bulk recording when inserting node
* ✔️ Fixing lint error
*  Minor refactoring + some temporary debugging logs
*  Preserving node properties when undoing it's removal, removing some unused repaint code
*  Added undo/redo support for import workflow and node enable/disable
* 🔥 Removing some unused constant
*  Added undo/redo support for renaming nodes
*  Fixing rename history recording
*  Added undo/redo support for duplicating nodes
* 📈 Implemented telemetry events
* 🔨 A bit of refactoring
*  Fixing edgecases in removing connection and moving nodes
*  Handling case of adding duplicate nodes when going back and forward in history
*  Recording connections added directly to store
*  Moving main history reset after wf is opened
* 🔨 Simplifying rename recording
* 📈 Adding NDV telemetry event, updating existing event name case
* 📈 Updating telemetry events
*  Fixing duplicate connections on undo/redo
*  Stopping undo events from firing constantly on keydown
* 📈 Updated telemetry event for hitting undo in NDV
*  Adding undo support for disabling nodes using keyboard shortcuts
*  Preventing adding duplicate connection commands to history
*  Clearing redo stack when new change is added
*  Preventing adding connection actions to undo stack while redoing them
* 👌 Addressing PR comments part 1
* 👌 Moving undo logic for disabling nodes to `NodeView`
* 👌 Implemented command comparing logic
*  Fix for not clearing redo stack on every user action
*  Fixing recording when moving nodes
*  Fixing undo for moving connections
*  Fixing tracking new nodes after latest merge
*  Fixing broken bulk delete
*  Preventing undo/redo when not on main node view tab
* 👌 Addressing PR comments
* 👌 Addressing PR comment
This commit is contained in:
Milorad FIlipović
2022-12-09 15:07:37 +01:00
committed by GitHub
parent 38d7300d2a
commit b2aba48dfe
13 changed files with 764 additions and 86 deletions

View File

@@ -0,0 +1,117 @@
import { MAIN_HEADER_TABS } from './../constants';
import { useNDVStore } from '@/stores/ndv';
import { BulkCommand, Undoable } from '@/models/history';
import { useHistoryStore } from '@/stores/history';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { mapStores } from 'pinia';
import mixins from 'vue-typed-mixins';
import { Command } from '@/models/history';
import { debounceHelper } from '@/mixins/debounce';
import { deviceSupportHelpers } from '@/mixins/deviceSupportHelpers';
import Vue from 'vue';
import { getNodeViewTab } from '@/utils';
const UNDO_REDO_DEBOUNCE_INTERVAL = 100;
export const historyHelper = mixins(debounceHelper, deviceSupportHelpers).extend({
computed: {
...mapStores(
useNDVStore,
useHistoryStore,
useUIStore,
useWorkflowsStore,
),
isNDVOpen(): boolean {
return this.ndvStore.activeNodeName !== null;
},
},
mounted() {
document.addEventListener('keydown', this.handleKeyDown);
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyDown);
},
methods: {
handleKeyDown(event: KeyboardEvent) {
const currentNodeViewTab = getNodeViewTab(this.$route);
if (event.repeat || currentNodeViewTab !== MAIN_HEADER_TABS.WORKFLOW) return;
if (this.isCtrlKeyPressed(event) && event.key === 'z') {
event.preventDefault();
if (!this.isNDVOpen) {
if (event.shiftKey) {
this.callDebounced('redo', { debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL, trailing: true });
} else {
this.callDebounced('undo', { debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL, trailing: true });
}
} else if (!event.shiftKey) {
this.trackUndoAttempt(event);
}
}
},
async undo() {
const command = this.historyStore.popUndoableToUndo();
if (!command) {
return;
}
if (command instanceof BulkCommand) {
this.historyStore.bulkInProgress = true;
const commands = command.commands;
const reverseCommands: Command[] = [];
for (let i = commands.length - 1; i >= 0; i--) {
await commands[i].revert();
reverseCommands.push(commands[i].getReverseCommand());
}
this.historyStore.pushUndoableToRedo(new BulkCommand(reverseCommands));
await Vue.nextTick();
this.historyStore.bulkInProgress = false;
}
if (command instanceof Command) {
await command.revert();
this.historyStore.pushUndoableToRedo(command.getReverseCommand());
this.uiStore.stateIsDirty = true;
}
this.trackCommand(command, 'undo');
},
async redo() {
const command = this.historyStore.popUndoableToRedo();
if (!command) {
return;
}
if (command instanceof BulkCommand) {
this.historyStore.bulkInProgress = true;
const commands = command.commands;
const reverseCommands = [];
for (let i = commands.length - 1; i >= 0; i--) {
await commands[i].revert();
reverseCommands.push(commands[i].getReverseCommand());
}
this.historyStore.pushBulkCommandToUndo(new BulkCommand(reverseCommands), false);
await Vue.nextTick();
this.historyStore.bulkInProgress = false;
}
if (command instanceof Command) {
await command.revert();
this.historyStore.pushCommandToUndo(command.getReverseCommand(), false);
this.uiStore.stateIsDirty = true;
}
this.trackCommand(command, 'redo');
},
trackCommand(command: Undoable, type: 'undo'|'redo'): void {
if (command instanceof Command) {
this.$telemetry.track(`User hit ${type}`, { commands_length: 1, commands: [ command.name ] });
} else if (command instanceof BulkCommand) {
this.$telemetry.track(`User hit ${type}`, { commands_length: command.commands.length, commands: command.commands.map(c => c.name) });
}
},
trackUndoAttempt(event: KeyboardEvent) {
if (this.isNDVOpen && !event.shiftKey) {
const activeNode = this.ndvStore.activeNode;
if (activeNode) {
this.$telemetry.track(`User hit undo in NDV`, { node_type: activeNode.type });
}
}
},
},
});

View File

@@ -13,6 +13,8 @@ import { useWorkflowsStore } from "@/stores/workflows";
import { useNodeTypesStore } from "@/stores/nodeTypes";
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { getStyleTokenValue } from "@/utils";
import { useHistoryStore } from "@/stores/history";
import { MoveNodeCommand } from "@/models/history";
export const nodeBase = mixins(
deviceSupportHelpers,
@@ -33,6 +35,7 @@ export const nodeBase = mixins(
useNodeTypesStore,
useUIStore,
useWorkflowsStore,
useHistoryStore,
),
data (): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
@@ -281,6 +284,9 @@ export const nodeBase = mixins(
moveNodes.push(this.data);
}
if(moveNodes.length > 1) {
this.historyStore.startRecordingUndo();
}
// This does for some reason just get called once for the node that got clicked
// even though "start" and "drag" gets called for all. So lets do for now
// some dirty DOM query to get the new positions till I have more time to
@@ -304,11 +310,16 @@ export const nodeBase = mixins(
position: newNodePosition,
},
};
this.workflowsStore.updateNodeProperties(updateInformation);
const oldPosition = node.position;
if (oldPosition[0] !== newNodePosition[0] || oldPosition[1] !== newNodePosition[1]) {
this.historyStore.pushCommandToUndo(new MoveNodeCommand(node.name, oldPosition, newNodePosition, this));
this.workflowsStore.updateNodeProperties(updateInformation);
this.$emit('moved', node);
}
});
this.$emit('moved', node);
if(moveNodes.length > 1) {
this.historyStore.stopRecordingUndo();
}
}
},
filter: '.node-description, .node-description .node-name, .node-description .node-subtitle',

View File

@@ -1,3 +1,5 @@
import { EnableNodeToggleCommand } from './../models/history';
import { useHistoryStore } from '@/stores/history';
import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
CUSTOM_API_CALL_KEY,
@@ -51,6 +53,7 @@ export const nodeHelpers = mixins(
computed: {
...mapStores(
useCredentialsStore,
useHistoryStore,
useNodeTypesStore,
useSettingsStore,
useWorkflowsStore,
@@ -431,13 +434,17 @@ export const nodeHelpers = mixins(
return returnData;
},
disableNodes(nodes: INodeUi[]) {
disableNodes(nodes: INodeUi[], trackHistory = false) {
if (trackHistory) {
this.historyStore.startRecordingUndo();
}
for (const node of nodes) {
const oldState = node.disabled;
// Toggle disabled flag
const updateInformation = {
name: node.name,
properties: {
disabled: !node.disabled,
disabled: !oldState,
} as IDataObject,
} as INodeUpdatePropertiesInformation;
@@ -447,6 +454,12 @@ export const nodeHelpers = mixins(
this.workflowsStore.clearNodeExecutionData(node.name);
this.updateNodeParameterIssues(node);
this.updateNodeCredentialIssues(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new EnableNodeToggleCommand(node.name, oldState === true, node.disabled === true, this));
}
}
if (trackHistory) {
this.historyStore.stopRecordingUndo();
}
},
// @ts-ignore