Skip to content

Commit b0a5f67

Browse files
committed
Move shortcut effects to their own file
1 parent d87d671 commit b0a5f67

File tree

2 files changed

+244
-230
lines changed

2 files changed

+244
-230
lines changed
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useEffect, useRef, useState } from 'react';
5+
6+
import { useStableCallback } from '@cloudscape-design/component-toolkit/internal';
7+
8+
import { getFirstScrollableParent } from '../../internal/utils/scrollable-containers';
9+
import { CaretController } from '../core/caret-controller';
10+
import { normalizeCaretIntoTrigger } from '../core/dom-utils';
11+
import { getPromptText, processTokens } from '../core/token-operations';
12+
import { isTriggerToken } from '../core/type-guards';
13+
import { PromptInputProps } from '../interfaces';
14+
15+
/** Tracks the state of a trigger element — its DOM node, dismissal, and cancellation status. */
16+
export interface TriggerState {
17+
element: HTMLElement | null;
18+
dismissed: boolean;
19+
dismissedValue: string | null;
20+
cancelled: boolean;
21+
}
22+
23+
export interface ShortcutsState {
24+
/** The ID of the trigger the caret is currently in, or null if not in any trigger. */
25+
caretInTrigger: string | null;
26+
setCaretInTrigger: (triggerId: string | null) => void;
27+
triggerStates: React.MutableRefObject<Map<string, TriggerState>>;
28+
lastSentTokens: React.MutableRefObject<readonly PromptInputProps.InputToken[] | undefined>;
29+
isExternalUpdate: (tokens: readonly PromptInputProps.InputToken[] | undefined) => boolean;
30+
markTokensAsSent: (tokens: readonly PromptInputProps.InputToken[]) => void;
31+
}
32+
33+
export function useShortcutsState(): ShortcutsState {
34+
const [caretInTrigger, setCaretInTrigger] = useState<string | null>(null);
35+
const triggerStates = useRef<Map<string, TriggerState>>(new Map());
36+
const lastSentTokens = useRef<readonly PromptInputProps.InputToken[] | undefined>(undefined);
37+
38+
const isExternalUpdate = useStableCallback((tokens: readonly PromptInputProps.InputToken[] | undefined): boolean => {
39+
return lastSentTokens.current !== tokens;
40+
});
41+
42+
const markTokensAsSent = useStableCallback((tokens: readonly PromptInputProps.InputToken[]) => {
43+
lastSentTokens.current = tokens;
44+
});
45+
46+
return {
47+
caretInTrigger,
48+
setCaretInTrigger,
49+
triggerStates,
50+
lastSentTokens,
51+
isExternalUpdate,
52+
markTokensAsSent,
53+
};
54+
}
55+
56+
interface ProcessorConfig {
57+
tokens?: readonly PromptInputProps.InputToken[];
58+
menus?: readonly PromptInputProps.MenuDefinition[];
59+
tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string;
60+
onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void;
61+
onTriggerDetected?: (detail: PromptInputProps.TriggerDetectedDetail) => boolean;
62+
state: ShortcutsState;
63+
}
64+
65+
export function useTokenProcessor(config: ProcessorConfig) {
66+
const { tokens, menus, tokensToText, onChange, onTriggerDetected, state } = config;
67+
const previousTokensRef = useRef(tokens);
68+
69+
const emitTokenChange = useStableCallback((newTokens: PromptInputProps.InputToken[]) => {
70+
const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens);
71+
state.markTokensAsSent(newTokens);
72+
onChange({ value, tokens: newTokens });
73+
});
74+
75+
const markCancelledTriggers = useStableCallback((cancelledIds: Set<string>) => {
76+
for (const id of cancelledIds) {
77+
const existing = state.triggerStates.current.get(id);
78+
if (existing) {
79+
existing.cancelled = true;
80+
} else {
81+
// Pre-populate — element will be filled in when renderTokens runs
82+
state.triggerStates.current.set(id, {
83+
element: null,
84+
dismissed: false,
85+
dismissedValue: null,
86+
cancelled: true,
87+
});
88+
}
89+
}
90+
});
91+
92+
const processUserInput = useStableCallback((inputTokens: PromptInputProps.InputToken[]) => {
93+
const { tokens: processed, cancelledIds } = processTokens(
94+
inputTokens,
95+
{ menus, tokensToText },
96+
{
97+
source: 'user-input',
98+
detectTriggers: true,
99+
},
100+
onTriggerDetected
101+
);
102+
103+
markCancelledTriggers(cancelledIds);
104+
emitTokenChange(processed);
105+
});
106+
107+
useEffect(() => {
108+
if (previousTokensRef.current === tokens) {
109+
return;
110+
}
111+
112+
previousTokensRef.current = tokens;
113+
114+
if (!state.isExternalUpdate(tokens)) {
115+
return;
116+
}
117+
118+
if (!tokens || !menus) {
119+
return;
120+
}
121+
122+
const { tokens: processed, cancelledIds } = processTokens(
123+
tokens,
124+
{ menus, tokensToText },
125+
{
126+
source: 'external',
127+
detectTriggers: true,
128+
}
129+
);
130+
131+
markCancelledTriggers(cancelledIds);
132+
133+
const hasChanges = processed.length !== tokens.length || processed.some((t, i) => t !== tokens[i]);
134+
135+
if (hasChanges) {
136+
emitTokenChange(processed);
137+
}
138+
}, [tokens, menus, tokensToText, state, emitTokenChange, markCancelledTriggers]);
139+
140+
return {
141+
processUserInput,
142+
};
143+
}
144+
145+
interface EffectsConfig {
146+
tokens?: readonly PromptInputProps.InputToken[];
147+
editableElementRef: React.RefObject<HTMLDivElement>;
148+
state: ShortcutsState;
149+
activeTriggerToken: PromptInputProps.TriggerToken | null;
150+
caretController: React.RefObject<CaretController | null>;
151+
}
152+
153+
export function useShortcutsEffects(config: EffectsConfig) {
154+
const { activeTriggerToken, editableElementRef, state, tokens, caretController } = config;
155+
156+
useEffect(() => {
157+
const hasTriggers = tokens?.some(isTriggerToken);
158+
159+
if (!hasTriggers || !editableElementRef.current) {
160+
state.setCaretInTrigger(null);
161+
return;
162+
}
163+
164+
const checkMenuState = () => {
165+
const ctrl = caretController.current;
166+
if (!editableElementRef.current || !ctrl) {
167+
return;
168+
}
169+
170+
const cancelledIds = new Set<string>();
171+
state.triggerStates.current.forEach((ts, id) => {
172+
if (ts.cancelled) {
173+
cancelledIds.add(id);
174+
}
175+
});
176+
normalizeCaretIntoTrigger(editableElementRef.current, cancelledIds);
177+
178+
const activeTrigger = ctrl.findActiveTrigger();
179+
let activeTriggerIdForMenu: string | null = null;
180+
181+
if (activeTrigger) {
182+
const triggerState = state.triggerStates.current.get(activeTrigger.id);
183+
184+
// Skip cancelled triggers entirely
185+
if (triggerState?.cancelled) {
186+
activeTriggerIdForMenu = null;
187+
} else {
188+
activeTriggerIdForMenu = activeTrigger.id;
189+
190+
// Check dismissed state — reopen if filter text changed since dismissal
191+
if (triggerState?.dismissed) {
192+
const currentValue = activeTrigger.textContent?.slice(1) ?? '';
193+
if (currentValue !== triggerState.dismissedValue) {
194+
triggerState.dismissed = false;
195+
triggerState.dismissedValue = null;
196+
} else {
197+
activeTriggerIdForMenu = null;
198+
}
199+
}
200+
}
201+
202+
// Clear dismissed state on all other triggers — navigating away resets dismissal
203+
state.triggerStates.current.forEach((ts, id) => {
204+
if (id !== activeTrigger.id && ts.dismissed) {
205+
ts.dismissed = false;
206+
ts.dismissedValue = null;
207+
}
208+
});
209+
} else {
210+
// Caret is not in any trigger — clear all dismissed states
211+
state.triggerStates.current.forEach(ts => {
212+
if (ts.dismissed) {
213+
ts.dismissed = false;
214+
ts.dismissedValue = null;
215+
}
216+
});
217+
}
218+
219+
if (activeTriggerIdForMenu !== state.caretInTrigger) {
220+
state.setCaretInTrigger(activeTriggerIdForMenu);
221+
}
222+
};
223+
224+
checkMenuState();
225+
226+
const ownerDoc = editableElementRef.current.ownerDocument;
227+
ownerDoc.addEventListener('selectionchange', checkMenuState);
228+
229+
const scrollableParent = getFirstScrollableParent(editableElementRef.current);
230+
if (scrollableParent) {
231+
scrollableParent.addEventListener('scroll', checkMenuState);
232+
}
233+
234+
return () => {
235+
ownerDoc.removeEventListener('selectionchange', checkMenuState);
236+
if (scrollableParent) {
237+
scrollableParent.removeEventListener('scroll', checkMenuState);
238+
}
239+
};
240+
}, [tokens, state, editableElementRef, caretController, activeTriggerToken]);
241+
}

0 commit comments

Comments
 (0)