From e1d559db69e9a8fe0186f79719328c0702a55c97 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 27 Aug 2024 17:10:01 +1000
Subject: [PATCH] feat(ui): tuned canvas undo/redo
- Throttle pushing to history for actions of the same type, starting with 1000ms throttle.
- History has a limit of 64 items, same as workflow editor
- Add clear history button
- Fix an issue where entity transformers would reset the entity state when the entity is fully transparent, resetting the redo stack. This could happen when you undo to the starting state of a layer
---
invokeai/frontend/web/public/locales/en.json | 1 +
invokeai/frontend/web/src/app/store/store.ts | 4 +-
.../CanvasSettingsClearHistoryButton.tsx | 20 +++++++
.../Settings/CanvasSettingsPopover.tsx | 2 +
.../components/UndoRedoButtonGroup.tsx | 13 +++--
.../controlLayers/konva/CanvasTransformer.ts | 10 ++--
.../controlLayers/store/canvasSlice.ts | 53 ++++++++++++++++++-
.../features/controlLayers/store/selectors.ts | 3 ++
8 files changed, 92 insertions(+), 14 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsClearHistoryButton.tsx
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index abfafcdbf7..ba14fb2891 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1650,6 +1650,7 @@
"storeNotInitialized": "Store is not initialized"
},
"controlLayers": {
+ "clearHistory": "Clear History",
"generateMode": "Generate",
"generateModeDesc": "Create individual images. Generated images are added directly to the gallery.",
"composeMode": "Compose",
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index 00bdde8ff5..2acdf584fd 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -8,7 +8,7 @@ import { deepClone } from 'common/util/deepClone';
import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice';
import { canvasSessionPersistConfig, canvasSessionSlice } from 'features/controlLayers/store/canvasSessionSlice';
import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
-import { canvasPersistConfig, canvasSlice } from 'features/controlLayers/store/canvasSlice';
+import { canvasPersistConfig, canvasSlice, canvasUndoableConfig } from 'features/controlLayers/store/canvasSlice';
import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice';
import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice';
import { toolPersistConfig, toolSlice } from 'features/controlLayers/store/toolSlice';
@@ -58,7 +58,7 @@ const allReducers = {
[queueSlice.name]: queueSlice.reducer,
[workflowSlice.name]: workflowSlice.reducer,
[hrfSlice.name]: hrfSlice.reducer,
- [canvasSlice.name]: undoable(canvasSlice.reducer),
+ [canvasSlice.name]: undoable(canvasSlice.reducer, canvasUndoableConfig),
[workflowSettingsSlice.name]: workflowSettingsSlice.reducer,
[upscaleSlice.name]: upscaleSlice.reducer,
[stylePresetSlice.name]: stylePresetSlice.reducer,
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsClearHistoryButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsClearHistoryButton.tsx
new file mode 100644
index 0000000000..2e11898986
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsClearHistoryButton.tsx
@@ -0,0 +1,20 @@
+import { Button } from '@invoke-ai/ui-library';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { canvasClearHistory } from 'features/controlLayers/store/canvasSlice';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+export const CanvasSettingsClearHistoryButton = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const onClick = useCallback(() => {
+ dispatch(canvasClearHistory());
+ }, [dispatch]);
+ return (
+
+ );
+});
+
+CanvasSettingsClearHistoryButton.displayName = 'CanvasSettingsClearHistoryButton';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx
index 34f1a11e4e..f0e39e9afc 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx
@@ -11,6 +11,7 @@ import {
} from '@invoke-ai/ui-library';
import { CanvasSettingsAutoSaveCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsAutoSaveCheckbox';
import { CanvasSettingsClearCachesButton } from 'features/controlLayers/components/Settings/CanvasSettingsClearCachesButton';
+import { CanvasSettingsClearHistoryButton } from 'features/controlLayers/components/Settings/CanvasSettingsClearHistoryButton';
import { CanvasSettingsClipToBboxCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsClipToBboxCheckbox';
import { CanvasSettingsDynamicGridSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsDynamicGridSwitch';
import { CanvasSettingsInvertScrollCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsInvertScrollCheckbox';
@@ -60,6 +61,7 @@ const DebugSettings = () => {
+
>
);
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/UndoRedoButtonGroup.tsx b/invokeai/frontend/web/src/features/controlLayers/components/UndoRedoButtonGroup.tsx
index df90d86496..ec8ced184b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/UndoRedoButtonGroup.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/UndoRedoButtonGroup.tsx
@@ -1,28 +1,27 @@
/* eslint-disable i18next/no-literal-string */
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
+import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice';
+import { selectCanvasMayRedo, selectCanvasMayUndo } from 'features/controlLayers/store/selectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowClockwiseBold, PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { useDispatch } from 'react-redux';
-import { ActionCreators } from 'redux-undo';
export const UndoRedoButtonGroup = memo(() => {
const { t } = useTranslation();
const dispatch = useDispatch();
- const mayUndo = useAppSelector(() => true);
+ const mayUndo = useAppSelector(selectCanvasMayUndo);
const handleUndo = useCallback(() => {
- // TODO(psyche): Implement undo
- dispatch(ActionCreators.undo());
+ dispatch(canvasUndo());
}, [dispatch]);
useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo, preventDefault: true }, [mayUndo, handleUndo]);
- const mayRedo = useAppSelector(() => true);
+ const mayRedo = useAppSelector(selectCanvasMayRedo);
const handleRedo = useCallback(() => {
- // TODO(psyche): Implement redo
- dispatch(ActionCreators.redo());
+ dispatch(canvasRedo());
}, [dispatch]);
useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [
mayRedo,
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts
index 9c8e316851..b5daddf355 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts
@@ -601,10 +601,12 @@ export class CanvasTransformer extends CanvasModuleBase {
// If the bbox has no width or height, that means the layer is fully transparent. This can happen if it is only
// eraser lines, fully clipped brush lines or if it has been fully erased.
if (this.pixelRect.width === 0 || this.pixelRect.height === 0) {
- // We shouldn't reset on the first render - the bbox will be calculated on the next render
- // The layer is fully transparent but has objects - reset it
- this.manager.stateApi.resetEntity({ entityIdentifier: this.parent.getEntityIdentifier() });
- this.syncInteractionState();
+ // If the layer already has no objects, we don't need to reset the entity state. This would cause a push to the
+ // undo stack and clear the redo stack.
+ if (this.parent.renderer.hasObjects()) {
+ this.manager.stateApi.resetEntity({ entityIdentifier: this.parent.getEntityIdentifier() });
+ this.syncInteractionState();
+ }
} else {
this.syncInteractionState();
this.update(this.parent.state.position, this.pixelRect);
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
index 9a466c70ea..3b0a729c35 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
@@ -1,4 +1,4 @@
-import type { PayloadAction } from '@reduxjs/toolkit';
+import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig } from 'app/store/store';
import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/util/arrayUtils';
@@ -27,6 +27,7 @@ import type { AspectRatioID } from 'features/parameters/components/DocumentSize/
import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import type { IRect } from 'konva/lib/types';
import { isEqual, merge, omit } from 'lodash-es';
+import type { UndoableOptions } from 'redux-undo';
import type { ControlNetModelConfig, ImageDTO, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types';
import { assert } from 'tsafe';
@@ -1032,6 +1033,9 @@ export const canvasSlice = createSlice({
newState.bbox.scaledSize = scaledSize;
return newState;
},
+ canvasUndo: () => {},
+ canvasRedo: () => {},
+ canvasClearHistory: () => {},
},
extraReducers(builder) {
builder.addCase(modelChanged, (state, action) => {
@@ -1062,6 +1066,9 @@ export const canvasSlice = createSlice({
export const {
canvasReset,
+ canvasUndo,
+ canvasRedo,
+ canvasClearHistory,
// All entities
entitySelected,
entityNameChanged,
@@ -1154,3 +1161,47 @@ const syncScaledSize = (state: CanvasState) => {
state.bbox.scaledSize = getScaledBoundingBoxDimensions({ width, height }, state.bbox.optimalDimension);
}
};
+
+let filter = true;
+
+export const canvasUndoableConfig: UndoableOptions = {
+ limit: 64,
+ undoType: canvasUndo.type,
+ redoType: canvasRedo.type,
+ clearHistoryType: canvasClearHistory.type,
+ filter: (action, _state, _history) => {
+ // Ignore all actions from other slices
+ if (!action.type.startsWith(canvasSlice.name)) {
+ return false;
+ }
+ // Throttle rapid actions of the same type
+ filter = actionsThrottlingFilter(action);
+ return filter;
+ },
+ // This is pretty spammy, leave commented out unless you need it
+ // debug: import.meta.env.MODE === 'development',
+};
+
+// Store rapid actions of the same type at most once every x time.
+// See: https://github.com/omnidan/redux-undo/blob/master/examples/throttled-drag/util/undoFilter.js
+const THROTTLE_MS = 1000;
+let ignoreRapid = false;
+let prevActionType: string | null = null;
+function actionsThrottlingFilter(action: UnknownAction) {
+ // If the actions are of a different type, reset the throttle and allow the action
+ if (action.type !== prevActionType) {
+ ignoreRapid = false;
+ prevActionType = action.type;
+ return true;
+ }
+ // Else, if the actions are of the same type, throttle them. Ignore the action if the flag is set.
+ if (ignoreRapid) {
+ return false;
+ }
+ // We are allowing this action - set the flag and a timeout to clear it.
+ ignoreRapid = true;
+ window.setTimeout(() => {
+ ignoreRapid = false;
+ }, THROTTLE_MS);
+ return true;
+}
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts
index 9180680911..92acd8ac1e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts
@@ -190,3 +190,6 @@ export const selectIsSelectedEntityDrawable = createSelector(
return isDrawableEntityType(selectedEntityIdentifier.type);
}
);
+
+export const selectCanvasMayUndo = (state: RootState) => state.canvas.past.length > 0;
+export const selectCanvasMayRedo = (state: RootState) => state.canvas.future.length > 0;