Skip to content

Commit 28be8f0

Browse files
refactor(ui): simplify prompt history shortcuts
1 parent b50c44b commit 28be8f0

File tree

1 file changed

+87
-118
lines changed

1 file changed

+87
-118
lines changed

invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx

Lines changed: 87 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { Box, Flex, Textarea } from '@invoke-ai/ui-library';
22
import { useStore } from '@nanostores/react';
3-
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3+
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
44
import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize';
55
import {
6-
positivePromptAddedToHistory,
76
positivePromptChanged,
87
selectModelSupportsNegativePrompt,
98
selectPositivePrompt,
@@ -29,9 +28,10 @@ import {
2928
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
3029
import { selectAllowPromptExpansion } from 'features/system/store/configSlice';
3130
import { selectActiveTab } from 'features/ui/store/uiSelectors';
32-
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
31+
import React, { memo, useCallback, useMemo, useRef } from 'react';
3332
import type { HotkeyCallback } from 'react-hotkeys-hook';
3433
import { useTranslation } from 'react-i18next';
34+
import { useClickAway } from 'react-use';
3535
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
3636

3737
import { PositivePromptHistoryIconButton } from './PositivePromptHistory';
@@ -42,17 +42,90 @@ const persistOptions: Parameters<typeof usePersistedTextAreaSize>[2] = {
4242
initialHeight: 120,
4343
};
4444

45+
const usePromptHistory = () => {
46+
const store = useAppStore();
47+
const history = useAppSelector(selectPositivePromptHistory);
48+
49+
/**
50+
* This ref is populated only when the user navigates back in history. In other words, its presence is a proxy
51+
* for "are we currently browsing history?"
52+
*
53+
* When we are moving thru history, we will always have a stashedPrompt (the prompt before we started browsing)
54+
* and a historyIdx which is an index into the history array (0 = most recent, 1 = previous, etc).
55+
*/
56+
const stateRef = useRef<{ stashedPrompt: string; historyIdx: number } | null>(null);
57+
58+
const prev = useCallback(() => {
59+
if (history.length === 0) {
60+
// No history, nothing to do
61+
return;
62+
}
63+
let state = stateRef.current;
64+
if (!state) {
65+
// First time going "back" in history, init state
66+
state = { stashedPrompt: selectPositivePrompt(store.getState()), historyIdx: 0 };
67+
stateRef.current = state;
68+
} else {
69+
// Subsequent "back" in history, increment index
70+
if (state.historyIdx === history.length - 1) {
71+
// Already at the end of history, nothing to do
72+
return;
73+
}
74+
state.historyIdx = state.historyIdx + 1;
75+
}
76+
// We should go "back" in history
77+
const newPrompt = history[state.historyIdx];
78+
if (newPrompt === undefined) {
79+
// Shouldn't happen
80+
return;
81+
}
82+
store.dispatch(positivePromptChanged(newPrompt));
83+
}, [history, store]);
84+
const next = useCallback(() => {
85+
if (history.length === 0) {
86+
// No history, nothing to do
87+
return;
88+
}
89+
let state = stateRef.current;
90+
if (!state) {
91+
// If the user hasn't gone "back" in history, "forward" does nothing
92+
return;
93+
}
94+
state.historyIdx = state.historyIdx - 1;
95+
if (state.historyIdx < 0) {
96+
// Overshot to the "current" stashed prompt
97+
store.dispatch(positivePromptChanged(state.stashedPrompt));
98+
// Clear state bc we're back to current prompt
99+
stateRef.current = null;
100+
return;
101+
}
102+
// We should go "forward" in history
103+
const newPrompt = history[state.historyIdx];
104+
if (newPrompt === undefined) {
105+
// Shouldn't happen
106+
return;
107+
}
108+
store.dispatch(positivePromptChanged(newPrompt));
109+
}, [history, store]);
110+
const reset = useCallback(() => {
111+
// Clear stashed state - used when user clicks away or types in the prompt box
112+
stateRef.current = null;
113+
}, []);
114+
return { prev, next, reset };
115+
};
116+
45117
export const ParamPositivePrompt = memo(() => {
46118
const dispatch = useAppDispatch();
47119
const prompt = useAppSelector(selectPositivePrompt);
48-
const history = useAppSelector(selectPositivePromptHistory);
49120
const viewMode = useAppSelector(selectStylePresetViewMode);
50121
const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId);
51122
const modelSupportsNegativePrompt = useAppSelector(selectModelSupportsNegativePrompt);
52123
const { isPending: isPromptExpansionPending } = useStore(promptExpansionApi.$state);
53124
const isPromptExpansionEnabled = useAppSelector(selectAllowPromptExpansion);
54125
const activeTab = useAppSelector(selectActiveTab);
55126

127+
const promptHistoryApi = usePromptHistory();
128+
56129
const textareaRef = useRef<HTMLTextAreaElement>(null);
57130
usePersistedTextAreaSize('positive_prompt', textareaRef, persistOptions);
58131

@@ -70,8 +143,11 @@ export const ParamPositivePrompt = memo(() => {
70143
const handleChange = useCallback(
71144
(v: string) => {
72145
dispatch(positivePromptChanged(v));
146+
// When the user changes the prompt, reset the prompt history state. This event is not fired when the prompt is
147+
// changed via the prompt history navigation.
148+
promptHistoryApi.reset();
73149
},
74-
[dispatch]
150+
[dispatch, promptHistoryApi]
75151
);
76152
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({
77153
prompt,
@@ -80,21 +156,8 @@ export const ParamPositivePrompt = memo(() => {
80156
isDisabled: isPromptExpansionPending,
81157
});
82158

83-
// Browsing state for boundary Up/Down traversal
84-
const browsingIndexRef = useRef<number | null>(null); // null => not browsing; 0..n => index in history
85-
const preBrowsePromptRef = useRef<string>(''); // original prompt when browsing started
86-
const lastHistoryFirstRef = useRef<string | undefined>(undefined);
87-
88-
// Reset browsing when history updates due to a new generation (first item changes or history mutates)
89-
useEffect(() => {
90-
if (lastHistoryFirstRef.current !== history[0]) {
91-
browsingIndexRef.current = null;
92-
preBrowsePromptRef.current = '';
93-
lastHistoryFirstRef.current = history[0];
94-
}
95-
}, [history]);
96-
97-
// Boundary navigation via Up/Down keys was replaced by explicit hotkeys below.
159+
// When the user clicks away from the textarea, reset the prompt history state.
160+
useClickAway(textareaRef, promptHistoryApi.reset);
98161

99162
const focus: HotkeyCallback = useCallback(
100163
(e) => {
@@ -115,124 +178,30 @@ export const ParamPositivePrompt = memo(() => {
115178
// Helper: check if prompt textarea is focused
116179
const isPromptFocused = useCallback(() => document.activeElement === textareaRef.current, []);
117180

118-
// Compute a starting working history and ensure current prompt is bumped into history
119-
const startBrowsing = useCallback(() => {
120-
if (browsingIndexRef.current !== null) {
121-
return;
122-
}
123-
preBrowsePromptRef.current = prompt ?? '';
124-
const trimmedCurrent = (prompt ?? '').trim();
125-
if (trimmedCurrent) {
126-
dispatch(positivePromptAddedToHistory(trimmedCurrent));
127-
}
128-
browsingIndexRef.current = 0;
129-
}, [dispatch, prompt]);
130-
131-
const applyHistoryAtIndex = useCallback(
132-
(idx: number, placeCaretAt: 'start' | 'end') => {
133-
const list = history;
134-
if (list.length === 0) {
135-
return;
136-
}
137-
const clamped = Math.max(0, Math.min(idx, list.length - 1));
138-
browsingIndexRef.current = clamped;
139-
const historyItem = list[clamped];
140-
if (historyItem !== undefined) {
141-
dispatch(positivePromptChanged(historyItem));
142-
}
143-
requestAnimationFrame(() => {
144-
const el = textareaRef.current;
145-
if (!el) {
146-
return;
147-
}
148-
if (placeCaretAt === 'start') {
149-
el.selectionStart = 0;
150-
el.selectionEnd = 0;
151-
} else {
152-
const end = el.value.length;
153-
el.selectionStart = end;
154-
el.selectionEnd = end;
155-
}
156-
});
157-
},
158-
[dispatch, history]
159-
);
160-
161-
const browsePrev = useCallback(() => {
162-
if (!isPromptFocused()) {
163-
return;
164-
}
165-
if (history.length === 0) {
166-
return;
167-
}
168-
if (browsingIndexRef.current === null) {
169-
startBrowsing();
170-
// Move to older entry on first activation
171-
if (history.length > 1) {
172-
applyHistoryAtIndex(1, 'start');
173-
} else {
174-
applyHistoryAtIndex(0, 'start');
175-
}
176-
return;
177-
}
178-
// Already browsing, go older if possible
179-
const next = Math.min((browsingIndexRef.current ?? 0) + 1, history.length - 1);
180-
applyHistoryAtIndex(next, 'start');
181-
}, [applyHistoryAtIndex, history.length, isPromptFocused, startBrowsing]);
182-
183-
const browseNext = useCallback(() => {
184-
if (!isPromptFocused()) {
185-
return;
186-
}
187-
if (history.length === 0) {
188-
return;
189-
}
190-
if (browsingIndexRef.current === null) {
191-
// Not browsing; Down does nothing (matches shell semantics)
192-
return;
193-
}
194-
if ((browsingIndexRef.current ?? 0) > 0) {
195-
const next = (browsingIndexRef.current ?? 0) - 1;
196-
applyHistoryAtIndex(next, 'end');
197-
} else {
198-
// Exit browsing and restore pre-browse prompt
199-
browsingIndexRef.current = null;
200-
dispatch(positivePromptChanged(preBrowsePromptRef.current));
201-
requestAnimationFrame(() => {
202-
const el = textareaRef.current;
203-
if (el) {
204-
const end = el.value.length;
205-
el.selectionStart = end;
206-
el.selectionEnd = end;
207-
}
208-
});
209-
}
210-
}, [applyHistoryAtIndex, dispatch, history.length, isPromptFocused]);
211-
212181
// Register hotkeys for browsing
213182
useRegisteredHotkeys({
214183
id: 'promptHistoryPrev',
215184
category: 'app',
216185
callback: (e) => {
217186
if (isPromptFocused()) {
218187
e.preventDefault();
219-
browsePrev();
188+
promptHistoryApi.prev();
220189
}
221190
},
222191
options: { preventDefault: true, enableOnFormTags: ['INPUT', 'SELECT', 'TEXTAREA'] },
223-
dependencies: [browsePrev, isPromptFocused],
192+
dependencies: [promptHistoryApi.prev, isPromptFocused],
224193
});
225194
useRegisteredHotkeys({
226195
id: 'promptHistoryNext',
227196
category: 'app',
228197
callback: (e) => {
229198
if (isPromptFocused()) {
230199
e.preventDefault();
231-
browseNext();
200+
promptHistoryApi.next();
232201
}
233202
},
234203
options: { preventDefault: true, enableOnFormTags: ['INPUT', 'SELECT', 'TEXTAREA'] },
235-
dependencies: [browseNext, isPromptFocused],
204+
dependencies: [promptHistoryApi.next, isPromptFocused],
236205
});
237206

238207
const dndTargetData = useMemo(() => promptGenerationFromImageDndTarget.getData(), []);

0 commit comments

Comments
 (0)