@@ -5,7 +5,7 @@ import type {
55 TextAnalysis ,
66 TokenSnapshot ,
77} from 'interlinearizer' ;
8- import { createContext , useCallback , useContext , useMemo , useRef } from 'react' ;
8+ import { createContext , useCallback , useContext , useEffect , useMemo , useRef } from 'react' ;
99import type { ReactNode } from 'react' ;
1010import { Provider as ReduxProvider , useDispatch , useSelector , useStore } from 'react-redux' ;
1111import {
@@ -41,6 +41,14 @@ type CallbackRefs = {
4141 onSaveRef : { current : ( ( analysis : TextAnalysis ) => void ) | undefined } ;
4242 /** Ref to the `onGlossChange` spy prop of the nearest {@link AnalysisStoreProvider}. */
4343 onGlossChangeRef : { current : ( ( tokenRef : string , value : string ) => void ) | undefined } ;
44+ /**
45+ * Stable callback a gloss input calls to register (`true`) or unregister (`false`) itself as
46+ * currently holding uncommitted text. Gloss edits are committed to the store only on blur, so
47+ * without this the provider could not tell the parent that an edit is in progress until then. The
48+ * provider counts active editors and reports the 0↔non-0 crossings via the `onPendingEditsChange`
49+ * prop, driving the tab's unsaved indicator while the user types.
50+ */
51+ reportEditing : ( active : boolean ) => void ;
4452} ;
4553
4654/** Internal context that carries callback refs alongside the Redux {@link ReduxProvider}. */
@@ -70,6 +78,12 @@ type AnalysisStoreProviderProps = Readonly<{
7078 * effect on store behavior.
7179 */
7280 onGlossChange ?: ( tokenRef : string , value : string ) => void ;
81+ /**
82+ * Called with `true` when at least one gloss input begins holding uncommitted text and `false`
83+ * when none do (the last edit is committed on blur, reverted, or the input unmounts). Lets the
84+ * caller reflect in-progress typing in the tab's unsaved indicator before the edit commits.
85+ */
86+ onPendingEditsChange ?: ( pending : boolean ) => void ;
7387} > ;
7488
7589/**
@@ -83,6 +97,8 @@ type AnalysisStoreProviderProps = Readonly<{
8397 * @param props.analysisLanguage - BCP 47 tag for reading/writing gloss values
8498 * @param props.onSave - Callback receiving the updated `TextAnalysis` after each mutation
8599 * @param props.onGlossChange - Spy called after each gloss write; for test observability only
100+ * @param props.onPendingEditsChange - Called with whether any gloss input currently holds
101+ * uncommitted text, so the caller can show the unsaved indicator while the user types
86102 * @returns A context provider wrapping the subtree
87103 */
88104export function AnalysisStoreProvider ( {
@@ -91,6 +107,7 @@ export function AnalysisStoreProvider({
91107 analysisLanguage,
92108 onSave,
93109 onGlossChange,
110+ onPendingEditsChange,
94111} : AnalysisStoreProviderProps ) {
95112 // Lazy initialization: useRef(createStore()) would create and discard a store on every render
96113 const storeRef = useRef < ReturnType < typeof createAnalysisStore > | undefined > ( undefined ) ;
@@ -106,8 +123,24 @@ export function AnalysisStoreProvider({
106123 onSaveRef . current = onSave ;
107124 const onGlossChangeRef = useRef ( onGlossChange ) ;
108125 onGlossChangeRef . current = onGlossChange ;
109-
110- const callbackRefs = useMemo ( ( ) => ( { onSaveRef, onGlossChangeRef } ) , [ ] ) ;
126+ const onPendingEditsChangeRef = useRef ( onPendingEditsChange ) ;
127+ onPendingEditsChangeRef . current = onPendingEditsChange ;
128+
129+ // Count of gloss inputs currently holding uncommitted text. Kept in a ref (not state) so
130+ // registering/unregistering an editor never re-renders the provider; only the 0↔non-0 crossings
131+ // are forwarded to the parent, which owns the indicator.
132+ const editingCountRef = useRef ( 0 ) ;
133+ const reportEditing = useCallback ( ( active : boolean ) => {
134+ const wasPending = editingCountRef . current > 0 ;
135+ editingCountRef . current += active ? 1 : - 1 ;
136+ const isPending = editingCountRef . current > 0 ;
137+ if ( wasPending !== isPending ) onPendingEditsChangeRef . current ?.( isPending ) ;
138+ } , [ ] ) ;
139+
140+ const callbackRefs = useMemo (
141+ ( ) => ( { onSaveRef, onGlossChangeRef, reportEditing } ) ,
142+ [ reportEditing ] ,
143+ ) ;
111144
112145 return (
113146 < ReduxProvider store = { store } >
@@ -158,6 +191,25 @@ function useAnalysisSave(hookName: string): {
158191 return { callbacks, dispatch, save } ;
159192}
160193
194+ /**
195+ * Registers a gloss input as currently holding uncommitted text whenever `isEditing` is true,
196+ * unregistering it when `isEditing` turns false or the input unmounts. The provider aggregates
197+ * these across all inputs and reports whether any edit is in progress, so the tab's unsaved
198+ * indicator can light up the moment the user starts typing — gloss writes themselves are deferred
199+ * to blur, which would otherwise leave the indicator dark mid-edit.
200+ *
201+ * @param isEditing - Whether this input's draft currently differs from its committed value.
202+ * @throws When called outside an {@link AnalysisStoreProvider}.
203+ */
204+ export function useReportGlossEditing ( isEditing : boolean ) : void {
205+ const { reportEditing } = useRequiredCallbacks ( 'useReportGlossEditing' ) ;
206+ useEffect ( ( ) => {
207+ if ( ! isEditing ) return undefined ;
208+ reportEditing ( true ) ;
209+ return ( ) => reportEditing ( false ) ;
210+ } , [ isEditing , reportEditing ] ) ;
211+ }
212+
161213// #endregion
162214
163215// #region Token hooks
0 commit comments