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
This commit is contained in:
psychedelicious 2024-08-27 17:10:01 +10:00
parent 23a98e2ed6
commit e1d559db69
8 changed files with 92 additions and 14 deletions

View File

@ -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",

View File

@ -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,

View File

@ -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 (
<Button onClick={onClick} size="sm">
{t('controlLayers.clearHistory')}
</Button>
);
});
CanvasSettingsClearHistoryButton.displayName = 'CanvasSettingsClearHistoryButton';

View File

@ -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 = () => {
<CanvasSettingsClearCachesButton />
<CanvasSettingsRecalculateRectsButton />
<CanvasSettingsLogDebugInfoButton />
<CanvasSettingsClearHistoryButton />
</>
);
};

View File

@ -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,

View File

@ -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);

View File

@ -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<CanvasState, UnknownAction> = {
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;
}

View File

@ -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;