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:
committed by
GitHub
parent
38d7300d2a
commit
b2aba48dfe
117
packages/editor-ui/src/mixins/history.ts
Normal file
117
packages/editor-ui/src/mixins/history.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user