1
1
import { Box , Flex , Textarea } from '@invoke-ai/ui-library' ;
2
2
import { useStore } from '@nanostores/react' ;
3
- import { useAppDispatch , useAppSelector } from 'app/store/storeHooks' ;
3
+ import { useAppDispatch , useAppSelector , useAppStore } from 'app/store/storeHooks' ;
4
4
import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize' ;
5
5
import {
6
- positivePromptAddedToHistory ,
7
6
positivePromptChanged ,
8
7
selectModelSupportsNegativePrompt ,
9
8
selectPositivePrompt ,
@@ -29,9 +28,10 @@ import {
29
28
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData' ;
30
29
import { selectAllowPromptExpansion } from 'features/system/store/configSlice' ;
31
30
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' ;
33
32
import type { HotkeyCallback } from 'react-hotkeys-hook' ;
34
33
import { useTranslation } from 'react-i18next' ;
34
+ import { useClickAway } from 'react-use' ;
35
35
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets' ;
36
36
37
37
import { PositivePromptHistoryIconButton } from './PositivePromptHistory' ;
@@ -42,17 +42,90 @@ const persistOptions: Parameters<typeof usePersistedTextAreaSize>[2] = {
42
42
initialHeight : 120 ,
43
43
} ;
44
44
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
+
45
117
export const ParamPositivePrompt = memo ( ( ) => {
46
118
const dispatch = useAppDispatch ( ) ;
47
119
const prompt = useAppSelector ( selectPositivePrompt ) ;
48
- const history = useAppSelector ( selectPositivePromptHistory ) ;
49
120
const viewMode = useAppSelector ( selectStylePresetViewMode ) ;
50
121
const activeStylePresetId = useAppSelector ( selectStylePresetActivePresetId ) ;
51
122
const modelSupportsNegativePrompt = useAppSelector ( selectModelSupportsNegativePrompt ) ;
52
123
const { isPending : isPromptExpansionPending } = useStore ( promptExpansionApi . $state ) ;
53
124
const isPromptExpansionEnabled = useAppSelector ( selectAllowPromptExpansion ) ;
54
125
const activeTab = useAppSelector ( selectActiveTab ) ;
55
126
127
+ const promptHistoryApi = usePromptHistory ( ) ;
128
+
56
129
const textareaRef = useRef < HTMLTextAreaElement > ( null ) ;
57
130
usePersistedTextAreaSize ( 'positive_prompt' , textareaRef , persistOptions ) ;
58
131
@@ -70,8 +143,11 @@ export const ParamPositivePrompt = memo(() => {
70
143
const handleChange = useCallback (
71
144
( v : string ) => {
72
145
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 ( ) ;
73
149
} ,
74
- [ dispatch ]
150
+ [ dispatch , promptHistoryApi ]
75
151
) ;
76
152
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt ( {
77
153
prompt,
@@ -80,21 +156,8 @@ export const ParamPositivePrompt = memo(() => {
80
156
isDisabled : isPromptExpansionPending ,
81
157
} ) ;
82
158
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 ) ;
98
161
99
162
const focus : HotkeyCallback = useCallback (
100
163
( e ) => {
@@ -115,124 +178,30 @@ export const ParamPositivePrompt = memo(() => {
115
178
// Helper: check if prompt textarea is focused
116
179
const isPromptFocused = useCallback ( ( ) => document . activeElement === textareaRef . current , [ ] ) ;
117
180
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
-
212
181
// Register hotkeys for browsing
213
182
useRegisteredHotkeys ( {
214
183
id : 'promptHistoryPrev' ,
215
184
category : 'app' ,
216
185
callback : ( e ) => {
217
186
if ( isPromptFocused ( ) ) {
218
187
e . preventDefault ( ) ;
219
- browsePrev ( ) ;
188
+ promptHistoryApi . prev ( ) ;
220
189
}
221
190
} ,
222
191
options : { preventDefault : true , enableOnFormTags : [ 'INPUT' , 'SELECT' , 'TEXTAREA' ] } ,
223
- dependencies : [ browsePrev , isPromptFocused ] ,
192
+ dependencies : [ promptHistoryApi . prev , isPromptFocused ] ,
224
193
} ) ;
225
194
useRegisteredHotkeys ( {
226
195
id : 'promptHistoryNext' ,
227
196
category : 'app' ,
228
197
callback : ( e ) => {
229
198
if ( isPromptFocused ( ) ) {
230
199
e . preventDefault ( ) ;
231
- browseNext ( ) ;
200
+ promptHistoryApi . next ( ) ;
232
201
}
233
202
} ,
234
203
options : { preventDefault : true , enableOnFormTags : [ 'INPUT' , 'SELECT' , 'TEXTAREA' ] } ,
235
- dependencies : [ browseNext , isPromptFocused ] ,
204
+ dependencies : [ promptHistoryApi . next , isPromptFocused ] ,
236
205
} ) ;
237
206
238
207
const dndTargetData = useMemo ( ( ) => promptGenerationFromImageDndTarget . getData ( ) , [ ] ) ;
0 commit comments