From 543b7e57cabf88c83c930ad55e0dc73b1b526eef Mon Sep 17 00:00:00 2001 From: Vamil Gandhi <13998000+vamgan@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:01:07 -0400 Subject: [PATCH] feat(react): expose reactive selection capture state --- packages/react/README.md | 28 +++++++++-- .../useAskableRegionCapture.test.tsx | 49 +++++++++++++++++++ .../useAskableTextSelectionCapture.test.tsx | 45 +++++++++++++++++ packages/react/src/useAskableRegionCapture.ts | 6 +++ .../src/useAskableTextSelectionCapture.ts | 6 +++ site/docs/api/react.md | 25 ++++++++-- 6 files changed, 151 insertions(+), 8 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 53254c9..2b64849 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -316,6 +316,13 @@ function RegionTools() { Lasso area {capture.active && } + {capture.selectionState && ( + + )} ); } @@ -326,8 +333,10 @@ The lasso overlay ships with the core `ASKABLE_REGION_CAPTURE_THEME`. Pass region/circle fill, or lasso gradient for your app. Use `selectionAffordance` to keep the selected area visible and optionally show an anchored prompt that focuses by default. Pass `dismissible: true` to include -a built-in clear button. Call `capture.getSelection()` when the chat composer -needs the current pinned packet, selection geometry, and affordance element. +a built-in clear button. Read `capture.selectionState` when React should render +a confirmation chip, inline question input, or custom composer for the pinned +selection. Call `capture.getSelection()` when non-render code needs the current +pinned packet, selection geometry, and affordance element. Use `onSelectionChange(state)` to mirror that pinned context into external state; it receives `null` when the selection is cleared. @@ -368,14 +377,23 @@ function SelectionTools() { {selection.active && } + {selection.selectionState && ( + + )} ); } ``` -`selection.getSelection()` returns the current pinned text packet, selected -range metadata, and affordance element. It returns `null` after the selection is -cleared, dismissed, cancelled, or destroyed. +`selection.selectionState` updates when text is pinned, cleared, dismissed, +cancelled, or destroyed. Use it for app-rendered selected-text confirmation and +inline chat inputs. `selection.getSelection()` returns the same current pinned +text packet, selected range metadata, and affordance element for imperative +code. Use `onSelectionChange(state)` to keep chat input state aligned with the pinned text selection. diff --git a/packages/react/src/__tests__/useAskableRegionCapture.test.tsx b/packages/react/src/__tests__/useAskableRegionCapture.test.tsx index 0ab3e37..ae2ae93 100644 --- a/packages/react/src/__tests__/useAskableRegionCapture.test.tsx +++ b/packages/react/src/__tests__/useAskableRegionCapture.test.tsx @@ -33,6 +33,7 @@ describe('useAskableRegionCapture', () => { {String(capture.active)} {capture.lastPacket ? JSON.stringify(capture.lastPacket) : 'null'} {capture.getSelection() ? JSON.stringify(capture.getSelection()?.selection) : 'null'} + {capture.selectionState ? JSON.stringify(capture.selectionState.selection) : 'null'} ); } @@ -56,6 +57,7 @@ describe('useAskableRegionCapture', () => { expect(screen.getByTestId('active').textContent).toBe('false'); expect(screen.getByTestId('packet').textContent).not.toBe('null'); expect(screen.getByTestId('selected').textContent).not.toBe('null'); + expect(screen.getByTestId('selection-state').textContent).not.toBe('null'); }); const packet = JSON.parse(screen.getByTestId('packet').textContent!); @@ -77,6 +79,53 @@ describe('useAskableRegionCapture', () => { shape: 'region', bounds: { x: 20, y: 30, width: 60, height: 60 }, }); + expect(JSON.parse(screen.getByTestId('selection-state').textContent!)).toMatchObject({ + shape: 'region', + bounds: { x: 20, y: 30, width: 60, height: 60 }, + }); + }); + + it('exposes pinned selection state and clears it from React state', async () => { + function Consumer() { + const capture = useAskableRegionCapture({ selectionAffordance: true }); + + return ( +
+ + + {capture.selectionState ? capture.selectionState.selection.shape : 'null'} +
+ ); + } + + render(); + + act(() => { + fireEvent.click(screen.getByText('Start')); + }); + + const overlay = document.getElementById('askable-region-capture')!; + act(() => { + overlay.dispatchEvent(pointerEvent('pointerdown', 20, 30)); + overlay.dispatchEvent(pointerEvent('pointermove', 80, 90)); + overlay.dispatchEvent(pointerEvent('pointerup', 80, 90)); + }); + + await waitFor(() => { + expect(screen.getByTestId('selection-state').textContent).toBe('region'); + }); + + act(() => { + fireEvent.click(screen.getByText('Clear')); + }); + + await waitFor(() => { + expect(screen.getByTestId('selection-state').textContent).toBe('null'); + }); }); it('supports circle capture overrides at start time', async () => { diff --git a/packages/react/src/__tests__/useAskableTextSelectionCapture.test.tsx b/packages/react/src/__tests__/useAskableTextSelectionCapture.test.tsx index 0802902..a511ff4 100644 --- a/packages/react/src/__tests__/useAskableTextSelectionCapture.test.tsx +++ b/packages/react/src/__tests__/useAskableTextSelectionCapture.test.tsx @@ -88,6 +88,7 @@ describe('useAskableTextSelectionCapture', () => { {capture.lastPacket ? JSON.stringify(capture.lastPacket) : 'null'} {capture.getSelection() ? JSON.stringify(capture.getSelection()?.selection) : 'null'} + {capture.selectionState ? JSON.stringify(capture.selectionState.selection) : 'null'} ); } @@ -102,6 +103,7 @@ describe('useAskableTextSelectionCapture', () => { await waitFor(() => { expect(screen.getByTestId('packet').textContent).not.toBe('null'); expect(screen.getByTestId('selected').textContent).not.toBe('null'); + expect(screen.getByTestId('selection-state').textContent).not.toBe('null'); }); const packet = JSON.parse(screen.getByTestId('packet').textContent!); @@ -122,5 +124,48 @@ describe('useAskableTextSelectionCapture', () => { text: 'Selected React copy', selector: '#react-selection', }); + expect(JSON.parse(screen.getByTestId('selection-state').textContent!)).toMatchObject({ + text: 'Selected React copy', + selector: '#react-selection', + }); + }); + + it('exposes pinned selected text state and clears it from React state', async () => { + function Consumer() { + const capture = useAskableTextSelectionCapture({ + selectionAffordance: true, + }); + + return ( +
+ + + {capture.selectionState?.selection.text ?? 'null'} +
+ ); + } + + render(); + selectText('Pinned React text'); + + act(() => { + fireEvent.click(screen.getByText('Capture')); + }); + + await waitFor(() => { + expect(screen.getByTestId('selection-state').textContent).toBe('Pinned React text'); + }); + + act(() => { + fireEvent.click(screen.getByText('Clear')); + }); + + await waitFor(() => { + expect(screen.getByTestId('selection-state').textContent).toBe('null'); + }); }); }); diff --git a/packages/react/src/useAskableRegionCapture.ts b/packages/react/src/useAskableRegionCapture.ts index 1a7cda4..49f8955 100644 --- a/packages/react/src/useAskableRegionCapture.ts +++ b/packages/react/src/useAskableRegionCapture.ts @@ -19,6 +19,7 @@ export interface UseAskableRegionCaptureResult { active: boolean; lastPacket: WebContextPacket | null; lastSelection: AskableRegionCaptureSelection | null; + selectionState: AskableRegionCaptureState | null; start: (overrides?: Partial) => void; cancel: () => void; clearSelection: () => void; @@ -36,6 +37,7 @@ export function useAskableRegionCapture( const [active, setActive] = useState(false); const [lastPacket, setLastPacket] = useState(null); const [lastSelection, setLastSelection] = useState(null); + const [selectionState, setSelectionState] = useState(null); useEffect(() => { optionsRef.current = options; @@ -45,12 +47,14 @@ export function useAskableRegionCapture( handleRef.current?.destroy(); handleRef.current = null; setActive(false); + setSelectionState(null); }, []); const cancel = useCallback(() => { handleRef.current?.cancel(); handleRef.current = null; setActive(false); + setSelectionState(null); }, []); const clearSelection = useCallback(() => { @@ -82,6 +86,7 @@ export function useAskableRegionCapture( optionsRef.current.onCapture?.(packet, selection); }, onSelectionChange(state) { + setSelectionState(state); optionsRef.current.onSelectionChange?.(state); }, onCancel() { @@ -104,6 +109,7 @@ export function useAskableRegionCapture( active, lastPacket, lastSelection, + selectionState, start, cancel, clearSelection, diff --git a/packages/react/src/useAskableTextSelectionCapture.ts b/packages/react/src/useAskableTextSelectionCapture.ts index bdb0191..b0cc529 100644 --- a/packages/react/src/useAskableTextSelectionCapture.ts +++ b/packages/react/src/useAskableTextSelectionCapture.ts @@ -19,6 +19,7 @@ export interface UseAskableTextSelectionCaptureResult { active: boolean; lastPacket: WebContextPacket | null; lastSelection: AskableTextSelectionCaptureSelection | null; + selectionState: AskableTextSelectionCaptureState | null; start: (overrides?: Partial) => void; captureNow: (overrides?: Partial) => WebContextPacket | null; cancel: () => void; @@ -37,6 +38,7 @@ export function useAskableTextSelectionCapture( const [active, setActive] = useState(false); const [lastPacket, setLastPacket] = useState(null); const [lastSelection, setLastSelection] = useState(null); + const [selectionState, setSelectionState] = useState(null); useEffect(() => { optionsRef.current = options; @@ -46,12 +48,14 @@ export function useAskableTextSelectionCapture( handleRef.current?.destroy(); handleRef.current = null; setActive(false); + setSelectionState(null); }, []); const cancel = useCallback(() => { handleRef.current?.cancel(); handleRef.current = null; setActive(false); + setSelectionState(null); }, []); const clearSelection = useCallback(() => { @@ -78,6 +82,7 @@ export function useAskableTextSelectionCapture( optionsRef.current.onCapture?.(packet, selection); }, onSelectionChange(state) { + setSelectionState(state); optionsRef.current.onSelectionChange?.(state); }, onCancel() { @@ -115,6 +120,7 @@ export function useAskableTextSelectionCapture( active, lastPacket, lastSelection, + selectionState, start, captureNow, cancel, diff --git a/site/docs/api/react.md b/site/docs/api/react.md index 999cf61..46a92d0 100644 --- a/site/docs/api/react.md +++ b/site/docs/api/react.md @@ -405,6 +405,13 @@ function DashboardCapture() { Lasso area {capture.active && } + {capture.selectionState && ( + + )} ); } @@ -433,6 +440,7 @@ function DashboardCapture() { | `active` | `boolean` | Whether the overlay is currently active | | `lastPacket` | `WebContextPacket \| null` | Last captured packet | | `lastSelection` | `AskableRegionCaptureSelection \| null` | Last captured geometry; lasso includes point path metadata | +| `selectionState` | `AskableRegionCaptureState \| null` | Reactive pinned packet, selection, and selected-state affordance element for rendering confirmation UI | | `start(overrides?)` | `function` | Start capture, optionally overriding shape/intent/etc. | | `cancel()` | `function` | Cancel the active overlay | | `clearSelection()` | `function` | Remove the current persisted selected-state UI | @@ -468,6 +476,13 @@ function TextSelectionCapture() { {selection.active && } + {selection.selectionState && ( + + )} ); } @@ -477,6 +492,10 @@ function TextSelectionCapture() { `onSelectionChange`, `onCancel`, `ctx`, and packet options such as `includeViewport`, `source`, `intent`, `privacy`, and `provenance`. -**Returns:** `ctx`, `active`, `lastPacket`, `lastSelection`, `start(overrides?)`, -`captureNow(overrides?)`, `cancel()`, `clearSelection()`, `getSelection()`, -`destroy()`, and `isActive()`. +**Returns:** `ctx`, `active`, `lastPacket`, `lastSelection`, `selectionState`, +`start(overrides?)`, `captureNow(overrides?)`, `cancel()`, `clearSelection()`, +`getSelection()`, `destroy()`, and `isActive()`. + +Use `selectionState` when a React component should visibly confirm selected +context or show an inline question input. It mirrors `getSelection()` but +updates reactively when capture pins, clears, or dismisses selected context.