mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
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:
parent
23a98e2ed6
commit
e1d559db69
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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';
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user