Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,13 @@ function RegionTools() {
Lasso area
</button>
{capture.active && <button onClick={capture.cancel}>Cancel</button>}
{capture.selectionState && (
<SelectionComposer
packet={capture.selectionState.packet}
selection={capture.selectionState.selection}
onClear={capture.clearSelection}
/>
)}
</>
);
}
Expand All @@ -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.

Expand Down Expand Up @@ -368,14 +377,23 @@ function SelectionTools() {
<button onClick={() => selection.start()}>Watch selection</button>
<button onClick={() => selection.captureNow()}>Send selected text</button>
{selection.active && <button onClick={selection.cancel}>Cancel</button>}
{selection.selectionState && (
<SelectedTextComposer
packet={selection.selectionState.packet}
selection={selection.selectionState.selection}
onClear={selection.clearSelection}
/>
)}
</>
);
}
```

`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.

Expand Down
49 changes: 49 additions & 0 deletions packages/react/src/__tests__/useAskableRegionCapture.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('useAskableRegionCapture', () => {
<span data-testid="active">{String(capture.active)}</span>
<span data-testid="packet">{capture.lastPacket ? JSON.stringify(capture.lastPacket) : 'null'}</span>
<span data-testid="selected">{capture.getSelection() ? JSON.stringify(capture.getSelection()?.selection) : 'null'}</span>
<span data-testid="selection-state">{capture.selectionState ? JSON.stringify(capture.selectionState.selection) : 'null'}</span>
</div>
);
}
Expand All @@ -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!);
Expand All @@ -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 (
<div>
<button type="button" onClick={() => capture.start()}>
Start
</button>
<button type="button" onClick={() => capture.clearSelection()}>
Clear
</button>
<span data-testid="selection-state">{capture.selectionState ? capture.selectionState.selection.shape : 'null'}</span>
</div>
);
}

render(<Consumer />);

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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe('useAskableTextSelectionCapture', () => {
</button>
<span data-testid="packet">{capture.lastPacket ? JSON.stringify(capture.lastPacket) : 'null'}</span>
<span data-testid="selected">{capture.getSelection() ? JSON.stringify(capture.getSelection()?.selection) : 'null'}</span>
<span data-testid="selection-state">{capture.selectionState ? JSON.stringify(capture.selectionState.selection) : 'null'}</span>
</div>
);
}
Expand All @@ -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!);
Expand All @@ -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 (
<div>
<button type="button" onClick={() => capture.captureNow()}>
Capture
</button>
<button type="button" onClick={() => capture.clearSelection()}>
Clear
</button>
<span data-testid="selection-state">{capture.selectionState?.selection.text ?? 'null'}</span>
</div>
);
}

render(<Consumer />);
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');
});
});
});
6 changes: 6 additions & 0 deletions packages/react/src/useAskableRegionCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface UseAskableRegionCaptureResult {
active: boolean;
lastPacket: WebContextPacket | null;
lastSelection: AskableRegionCaptureSelection | null;
selectionState: AskableRegionCaptureState | null;
start: (overrides?: Partial<AskableRegionCaptureOptions>) => void;
cancel: () => void;
clearSelection: () => void;
Expand All @@ -36,6 +37,7 @@ export function useAskableRegionCapture(
const [active, setActive] = useState(false);
const [lastPacket, setLastPacket] = useState<WebContextPacket | null>(null);
const [lastSelection, setLastSelection] = useState<AskableRegionCaptureSelection | null>(null);
const [selectionState, setSelectionState] = useState<AskableRegionCaptureState | null>(null);

useEffect(() => {
optionsRef.current = options;
Expand All @@ -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(() => {
Expand Down Expand Up @@ -82,6 +86,7 @@ export function useAskableRegionCapture(
optionsRef.current.onCapture?.(packet, selection);
},
onSelectionChange(state) {
setSelectionState(state);
optionsRef.current.onSelectionChange?.(state);
},
onCancel() {
Expand All @@ -104,6 +109,7 @@ export function useAskableRegionCapture(
active,
lastPacket,
lastSelection,
selectionState,
start,
cancel,
clearSelection,
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/useAskableTextSelectionCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface UseAskableTextSelectionCaptureResult {
active: boolean;
lastPacket: WebContextPacket | null;
lastSelection: AskableTextSelectionCaptureSelection | null;
selectionState: AskableTextSelectionCaptureState | null;
start: (overrides?: Partial<AskableTextSelectionCaptureOptions>) => void;
captureNow: (overrides?: Partial<AskableTextSelectionCaptureOptions>) => WebContextPacket | null;
cancel: () => void;
Expand All @@ -37,6 +38,7 @@ export function useAskableTextSelectionCapture(
const [active, setActive] = useState(false);
const [lastPacket, setLastPacket] = useState<WebContextPacket | null>(null);
const [lastSelection, setLastSelection] = useState<AskableTextSelectionCaptureSelection | null>(null);
const [selectionState, setSelectionState] = useState<AskableTextSelectionCaptureState | null>(null);

useEffect(() => {
optionsRef.current = options;
Expand All @@ -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(() => {
Expand All @@ -78,6 +82,7 @@ export function useAskableTextSelectionCapture(
optionsRef.current.onCapture?.(packet, selection);
},
onSelectionChange(state) {
setSelectionState(state);
optionsRef.current.onSelectionChange?.(state);
},
onCancel() {
Expand Down Expand Up @@ -115,6 +120,7 @@ export function useAskableTextSelectionCapture(
active,
lastPacket,
lastSelection,
selectionState,
start,
captureNow,
cancel,
Expand Down
25 changes: 22 additions & 3 deletions site/docs/api/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,13 @@ function DashboardCapture() {
Lasso area
</button>
{capture.active && <button onClick={capture.cancel}>Cancel</button>}
{capture.selectionState && (
<SelectedContextComposer
packet={capture.selectionState.packet}
selection={capture.selectionState.selection}
onClear={capture.clearSelection}
/>
)}
</>
);
}
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -468,6 +476,13 @@ function TextSelectionCapture() {
<button onClick={() => selection.start()}>Watch selection</button>
<button onClick={() => selection.captureNow()}>Send selected text</button>
{selection.active && <button onClick={selection.cancel}>Cancel</button>}
{selection.selectionState && (
<SelectedTextComposer
packet={selection.selectionState.packet}
selection={selection.selectionState.selection}
onClear={selection.clearSelection}
/>
)}
</>
);
}
Expand All @@ -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.
Loading