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
25 changes: 21 additions & 4 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ ctx.destroy();
## Region, Circle, and Lasso Capture

Use `createAskableRegionCapture()` when the user should draw a page region,
circle an area, or lasso a freehand shape and send it as structured context.
square off a fixed-ratio area, circle an area, or lasso a freehand shape and
send it as structured context.

```ts
import {
Expand All @@ -61,10 +62,21 @@ const capture = createAskableRegionCapture(ctx, {
shape: 'lasso',
intent: 'explain this selected area',
includeViewport: true,
selectionAffordance: {
label: 'Selected context',
className: 'my-selection-marker',
prompt: {
placeholder: 'Ask about this area...',
onSubmit(question, packet) {
sendToAgent({ question, context: packet });
},
},
},
theme: {
...ASKABLE_REGION_CAPTURE_THEME,
lassoStrokeWidth: 4,
lassoGlowRadius: 12,
selectionAffordanceStroke: '#7c3aed',
},
onCapture(packet) {
sendToAgent(packet);
Expand All @@ -75,12 +87,17 @@ capture.start();
```

The packet uses `capture.mode` of `region`, `circle`, or `lasso`, marks consent
as explicit, and includes the selected geometry in `target.bounds`. Lasso
captures also include `target.metadata.points` for the freehand path.
as explicit, and includes the selected geometry in `target.bounds`. Square
captures use `capture.mode: 'region'` with `target.metadata.shape: 'square'`.
Lasso captures also include `target.metadata.points` for the freehand path.
The built-in lasso overlay uses `ASKABLE_REGION_CAPTURE_THEME` by default; pass
`theme` to override any overlay, selection, or lasso style for your app.
Set `selectionAffordance` to keep the selected shape visible after capture and,
optionally, render a small prompt anchored to the selected area. The affordance
accepts class names, inline style hooks, and a custom `render()` escape hatch.
Set `once: false` to keep the overlay mounted for repeated captures. The handle
reports active until `cancel()` or `destroy()` runs.
reports active until `cancel()` or `destroy()` runs, and `clearSelection()`
removes only the persisted selected-state UI.

## Text Selection Capture

Expand Down
168 changes: 168 additions & 0 deletions packages/core/src/__tests__/capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function pointerEvent(type: string, x: number, y: number): PointerEvent {

afterEach(() => {
document.getElementById('askable-region-capture')?.remove();
document.getElementById('askable-region-selection-affordance')?.remove();
});

describe('createAskableRegionCapture', () => {
Expand Down Expand Up @@ -105,6 +106,66 @@ describe('createAskableRegionCapture', () => {
ctx.destroy();
});

it('captures a square as constrained region geometry', () => {
const ctx = createAskableContext();
const onCapture = vi.fn();
const capture = createAskableRegionCapture(ctx, {
shape: 'square',
onCapture,
});

capture.start();

const overlay = document.getElementById('askable-region-capture')!;
overlay.dispatchEvent(pointerEvent('pointerdown', 50, 60));
overlay.dispatchEvent(pointerEvent('pointermove', 90, 140));
overlay.dispatchEvent(pointerEvent('pointerup', 90, 140));

const [packet, selection] = onCapture.mock.calls[0];
expect(selection).toMatchObject({
shape: 'square',
bounds: { x: 50, y: 60, width: 80, height: 80 },
});
expect(packet.capture).toMatchObject({
mode: 'region',
gesture: 'drag',
});
expect(packet.target).toMatchObject({
bounds: { x: 50, y: 60, width: 80, height: 80 },
metadata: {
shape: 'square',
},
});

capture.destroy();
ctx.destroy();
});

it('captures a square dragged up and left', () => {
const ctx = createAskableContext();
const onCapture = vi.fn();
const capture = createAskableRegionCapture(ctx, {
shape: 'square',
onCapture,
});

capture.start();

const overlay = document.getElementById('askable-region-capture')!;
overlay.dispatchEvent(pointerEvent('pointerdown', 90, 140));
overlay.dispatchEvent(pointerEvent('pointermove', 50, 60));
overlay.dispatchEvent(pointerEvent('pointerup', 50, 60));

const [, selection] = onCapture.mock.calls[0];
expect(selection).toMatchObject({
shape: 'square',
bounds: { x: 10, y: 60, width: 80, height: 80 },
});

capture.destroy();
ctx.destroy();
});

it('captures a lasso with point metadata', () => {
const ctx = createAskableContext();
const onCapture = vi.fn();
Expand Down Expand Up @@ -317,4 +378,111 @@ describe('createAskableRegionCapture', () => {

ctx.destroy();
});

it('can persist a selected region affordance after capture', () => {
const ctx = createAskableContext();
const capture = createAskableRegionCapture(ctx, {
selectionAffordance: {
label: 'Selected area',
className: 'custom-affordance',
style: { opacity: '0.92' },
},
});

capture.start();

const overlay = document.getElementById('askable-region-capture')!;
overlay.dispatchEvent(pointerEvent('pointerdown', 10, 20));
overlay.dispatchEvent(pointerEvent('pointermove', 80, 90));
overlay.dispatchEvent(pointerEvent('pointerup', 80, 90));

const affordance = document.getElementById('askable-region-selection-affordance')!;
expect(document.getElementById('askable-region-capture')).toBeNull();
expect(affordance).toBeInstanceOf(HTMLElement);
expect(affordance.getAttribute('data-askable-region-selection-affordance')).toBe('region');
expect(affordance.className).toBe('custom-affordance');
expect(affordance.style.left).toBe('10px');
expect(affordance.style.top).toBe('20px');
expect(affordance.style.width).toBe('70px');
expect(affordance.style.height).toBe('70px');
expect(affordance.style.opacity).toBe('0.92');
expect(affordance.textContent).toContain('Selected area');

capture.clearSelection();
expect(document.getElementById('askable-region-selection-affordance')).toBeNull();

capture.destroy();
ctx.destroy();
});

it('renders an anchored prompt and calls onSubmit with the captured packet', () => {
const ctx = createAskableContext();
const onSubmit = vi.fn();
const capture = createAskableRegionCapture(ctx, {
source: { app: 'dashboard' },
selectionAffordance: {
prompt: {
placeholder: 'Ask here',
submitLabel: 'Send question',
onSubmit,
},
},
});

capture.start();

const overlay = document.getElementById('askable-region-capture')!;
overlay.dispatchEvent(pointerEvent('pointerdown', 10, 20));
overlay.dispatchEvent(pointerEvent('pointermove', 80, 90));
overlay.dispatchEvent(pointerEvent('pointerup', 80, 90));

const affordance = document.getElementById('askable-region-selection-affordance')!;
const input = affordance.querySelector('input')!;
const button = affordance.querySelector('button')!;
input.value = 'What changed here?';
button.click();

expect(input.placeholder).toBe('Ask here');
expect(button.getAttribute('aria-label')).toBe('Send question');
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toBe('What changed here?');
expect(onSubmit.mock.calls[0][1]).toMatchObject({
source: { app: 'dashboard' },
capture: { mode: 'region' },
});
expect(onSubmit.mock.calls[0][2]).toMatchObject({
bounds: { x: 10, y: 20, width: 70, height: 70 },
});
expect(input.value).toBe('');

capture.destroy();
ctx.destroy();
});

it('can persist lasso affordance geometry', () => {
const ctx = createAskableContext();
const capture = createAskableRegionCapture(ctx, {
shape: 'lasso',
selectionAffordance: true,
});

capture.start();

const overlay = document.getElementById('askable-region-capture')!;
overlay.dispatchEvent(pointerEvent('pointerdown', 10, 20));
overlay.dispatchEvent(pointerEvent('pointermove', 30, 45));
overlay.dispatchEvent(pointerEvent('pointermove', 70, 35));
overlay.dispatchEvent(pointerEvent('pointerup', 80, 75));

const affordance = document.getElementById('askable-region-selection-affordance')!;
const path = affordance.querySelector('path')!;
expect(affordance.getAttribute('data-askable-region-selection-affordance')).toBe('lasso');
expect(affordance.style.left).toBe('10px');
expect(affordance.style.top).toBe('20px');
expect(path.getAttribute('d')).toBe('M0 0 L20 25 L60 15 L70 55');

capture.destroy();
expect(document.getElementById('askable-region-selection-affordance')).toBeNull();
ctx.destroy();
});
});
Loading
Loading