From 7f54c8cda9ff6371346d85871335304617b03484 Mon Sep 17 00:00:00 2001 From: Vamil Gandhi <13998000+vamgan@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:14:36 -0400 Subject: [PATCH] feat(core): add text selection affordances --- packages/core/README.md | 23 +- packages/core/src/__tests__/selection.test.ts | 93 ++++++ packages/core/src/index.ts | 6 +- packages/core/src/selection.ts | 291 +++++++++++++++++- packages/react/src/useAskableRegionCapture.ts | 7 +- .../src/useAskableTextSelectionCapture.ts | 8 +- packages/svelte/src/askable.ts | 15 +- packages/vue/src/useAskableRegionCapture.ts | 7 +- .../vue/src/useAskableTextSelectionCapture.ts | 8 +- site/docs/api/core.md | 33 +- site/docs/guide/context.md | 21 +- site/docs/guide/react.md | 13 +- site/www/index.html | 41 ++- 13 files changed, 548 insertions(+), 18 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index cc2ad27..c3e6847 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -105,7 +105,11 @@ Use `createAskableTextSelectionCapture()` when the user should highlight page text and send that exact selected range as structured context. ```ts -import { createAskableContext, createAskableTextSelectionCapture } from '@askable-ui/core'; +import { + ASKABLE_TEXT_SELECTION_CAPTURE_THEME, + createAskableContext, + createAskableTextSelectionCapture, +} from '@askable-ui/core'; const ctx = createAskableContext({ viewport: true }); ctx.observe(document); @@ -113,6 +117,19 @@ ctx.observe(document); const selection = createAskableTextSelectionCapture(ctx, { intent: 'answer using this selected text', includeViewport: true, + selectionAffordance: { + label: 'Selected text', + prompt: { + placeholder: 'Ask about this text...', + onSubmit(question, packet) { + sendToAgent({ question, context: packet }); + }, + }, + }, + theme: { + ...ASKABLE_TEXT_SELECTION_CAPTURE_THEME, + selectionFill: 'rgba(124,58,237,0.14)', + }, onCapture(packet) { sendToAgent(packet); }, @@ -124,6 +141,10 @@ selection.start(); The packet uses `capture.mode` of `text-selection`, marks consent as explicit, and includes the highlighted copy in `target.text`. Call `captureNow()` for button-driven flows where selection should be read on demand. +Set `selectionAffordance` to keep selected text visually marked after capture +and optionally attach a small prompt to the highlighted range. The affordance +accepts class names, inline styles, theme overrides, and a custom `render()` +escape hatch. ## API Reference diff --git a/packages/core/src/__tests__/selection.test.ts b/packages/core/src/__tests__/selection.test.ts index bcf8e8f..fefb60b 100644 --- a/packages/core/src/__tests__/selection.test.ts +++ b/packages/core/src/__tests__/selection.test.ts @@ -17,6 +17,9 @@ function selectText( Object.defineProperty(range, 'getBoundingClientRect', { value: () => bounds, }); + Object.defineProperty(range, 'getClientRects', { + value: () => [bounds], + }); const selection = document.getSelection()!; selection.removeAllRanges(); @@ -66,6 +69,7 @@ describe('createAskableTextSelectionCapture', () => { metadata: { kind: 'text-selection', length: 29, + rectCount: 1, }, }, privacy: { consent: 'explicit' }, @@ -79,6 +83,95 @@ describe('createAskableTextSelectionCapture', () => { ctx.destroy(); }); + it('can persist selected text affordance after capture', () => { + const ctx = createAskableContext(); + const capture = createAskableTextSelectionCapture(ctx, { + selectionAffordance: { + label: 'Quoted text', + className: 'custom-text-affordance', + style: { opacity: '0.9' }, + }, + }); + + selectText('Selected sentence.'); + capture.captureNow(); + + const affordance = document.getElementById('askable-text-selection-affordance')!; + expect(affordance).toBeInstanceOf(HTMLElement); + expect(affordance.getAttribute('data-askable-text-selection-affordance')).toBe('true'); + expect(affordance.className).toBe('custom-text-affordance'); + expect(affordance.style.opacity).toBe('0.9'); + expect(affordance.textContent).toContain('Quoted text'); + expect(affordance.querySelectorAll('span').length).toBeGreaterThanOrEqual(2); + + capture.clearSelection(); + expect(document.getElementById('askable-text-selection-affordance')).toBeNull(); + + capture.destroy(); + ctx.destroy(); + }); + + it('renders an anchored selected text prompt and calls onSubmit with the captured packet', () => { + const ctx = createAskableContext(); + const onSubmit = vi.fn(); + const capture = createAskableTextSelectionCapture(ctx, { + source: { app: 'reader' }, + selectionAffordance: { + prompt: { + placeholder: 'Ask about quote', + submitLabel: 'Send text question', + onSubmit, + }, + }, + }); + + selectText('Explain this sentence.'); + capture.captureNow(); + + const affordance = document.getElementById('askable-text-selection-affordance')!; + const input = affordance.querySelector('input')!; + const button = affordance.querySelector('button')!; + input.value = 'What does this mean?'; + button.click(); + + expect(input.placeholder).toBe('Ask about quote'); + expect(button.getAttribute('aria-label')).toBe('Send text question'); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toBe('What does this mean?'); + expect(onSubmit.mock.calls[0][1]).toMatchObject({ + source: { app: 'reader' }, + capture: { mode: 'text-selection' }, + target: { text: 'Explain this sentence.' }, + }); + expect(onSubmit.mock.calls[0][2]).toMatchObject({ + text: 'Explain this sentence.', + bounds: { x: 12, y: 20, width: 80, height: 18 }, + }); + expect(input.value).toBe(''); + + capture.destroy(); + ctx.destroy(); + }); + + it('keeps the selected text affordance when once stops listeners', () => { + const ctx = createAskableContext(); + const capture = createAskableTextSelectionCapture(ctx, { + once: true, + selectionAffordance: true, + }); + + capture.start(); + selectText('One shot selected text.'); + const packet = capture.captureNow(); + + expect(packet).not.toBeNull(); + expect(capture.isActive()).toBe(false); + expect(document.getElementById('askable-text-selection-affordance')).toBeInstanceOf(HTMLElement); + + capture.destroy(); + ctx.destroy(); + }); + it('ignores selections outside the configured root', () => { const root = document.createElement('section'); const outside = selectText('Outside selection'); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bb89f28..926402d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export { createAskableInspector } from './inspector.js'; export { ASKABLE_REGION_CAPTURE_THEME, createAskableRegionCapture } from './capture.js'; -export { createAskableTextSelectionCapture } from './selection.js'; +export { ASKABLE_TEXT_SELECTION_CAPTURE_THEME, createAskableTextSelectionCapture } from './selection.js'; export { a11yTextExtractor } from './a11y.js'; export { createAskableCollectionSource, createAskableSource } from './sources.js'; export { createAskablePageSource } from './page-source.js'; @@ -43,9 +43,13 @@ export type { AskableRegionCaptureTheme, } from './capture.js'; export type { + AskableTextSelectionCaptureAffordanceOptions, AskableTextSelectionCaptureHandle, AskableTextSelectionCaptureOptions, + AskableTextSelectionCapturePromptOptions, AskableTextSelectionCaptureSelection, + AskableTextSelectionCaptureStyle, + AskableTextSelectionCaptureTheme, } from './selection.js'; export type { AskableCollectionSourceData, diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index 2ec778b..3742209 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -11,11 +11,78 @@ import type { export interface AskableTextSelectionCaptureSelection { text: string; bounds?: WebContextRect; + rects?: WebContextRect[]; selector?: string; pointerType?: string; capturedAt: string; } +export type AskableTextSelectionCaptureStyle = Partial; + +export interface AskableTextSelectionCapturePromptOptions { + /** Placeholder shown in the anchored prompt input. */ + placeholder?: string; + /** Accessible label/title for the submit button. */ + submitLabel?: string; + /** Class added to the prompt container. */ + className?: string; + /** Inline styles applied to the prompt container. */ + style?: AskableTextSelectionCaptureStyle; + /** Class added to the prompt input. */ + inputClassName?: string; + /** Inline styles applied to the prompt input. */ + inputStyle?: AskableTextSelectionCaptureStyle; + /** Class added to the prompt submit button. */ + buttonClassName?: string; + /** Inline styles applied to the prompt submit button. */ + buttonStyle?: AskableTextSelectionCaptureStyle; + /** Called when the user submits a non-empty prompt from the selected text. */ + onSubmit?: ( + question: string, + packet: WebContextPacket, + selection: AskableTextSelectionCaptureSelection, + ) => void; +} + +export interface AskableTextSelectionCaptureAffordanceOptions { + /** Keep the selected text range visible after capture. Defaults to true when enabled. */ + persist?: boolean; + /** Render a compact prompt input anchored to the selected text. Defaults to false. */ + prompt?: boolean | AskableTextSelectionCapturePromptOptions; + /** Optional label shown beside the selected text. */ + label?: string; + /** Class added to the selected-text affordance root. */ + className?: string; + /** Inline styles applied to the selected-text affordance root. */ + style?: AskableTextSelectionCaptureStyle; + /** Class added to the selected-text label. */ + labelClassName?: string; + /** Inline styles applied to the selected-text label. */ + labelStyle?: AskableTextSelectionCaptureStyle; + /** Replace the built-in selected-text affordance with consumer-rendered DOM. */ + render?: ( + packet: WebContextPacket, + selection: AskableTextSelectionCaptureSelection, + ) => HTMLElement | null | undefined | void; +} + +export interface AskableTextSelectionCaptureTheme { + /** Fill used for persisted selected text marks. */ + selectionFill: string; + /** Outline used for persisted selected text marks. */ + selectionOutline: string; + /** Shadow used for persisted selected text marks. */ + selectionShadow: string; + /** Background color for the anchored prompt input. */ + promptBackground: string; + /** Border color for the anchored prompt input. */ + promptBorder: string; + /** Text color for the anchored prompt input. */ + promptText: string; + /** Accent color for the anchored prompt submit button. */ + promptAccent: string; +} + export interface AskableTextSelectionCaptureOptions extends Omit { /** Root to observe for selections. Defaults to document. */ root?: Document | HTMLElement; @@ -27,6 +94,10 @@ export interface AskableTextSelectionCaptureOptions extends Omit; + /** Opt-in selected-state UI shown after capture, optionally with an anchored prompt. */ + selectionAffordance?: boolean | AskableTextSelectionCaptureAffordanceOptions; /** Called after selected text is serialized to a Context packet. */ onCapture?: (packet: WebContextPacket, selection: AskableTextSelectionCaptureSelection) => void; /** Called when active selection capture is cancelled. */ @@ -37,6 +108,7 @@ export interface AskableTextSelectionCaptureHandle { start(): void; captureNow(overrides?: Partial): WebContextPacket | null; cancel(): void; + clearSelection(): void; destroy(): void; isActive(): boolean; } @@ -47,6 +119,17 @@ type LastInteraction = { }; const DEFAULT_DEBOUNCE = 120; +const AFFORDANCE_ID = 'askable-text-selection-affordance'; +const AFFORDANCE_ATTR = 'data-askable-text-selection-affordance'; +export const ASKABLE_TEXT_SELECTION_CAPTURE_THEME: AskableTextSelectionCaptureTheme = { + selectionFill: 'rgba(124,58,237,0.14)', + selectionOutline: 'rgba(124,58,237,0.28)', + selectionShadow: '0 8px 22px rgba(91,33,182,0.1)', + promptBackground: '#ffffff', + promptBorder: 'rgba(124,58,237,0.22)', + promptText: '#111317', + promptAccent: '#111317', +}; /** * Listens for text selection changes in the document and emits an Askable context packet @@ -77,6 +160,7 @@ export function createAskableTextSelectionCapture( start: () => undefined, captureNow: () => null, cancel: () => undefined, + clearSelection: () => undefined, destroy: () => undefined, isActive: () => false, }; @@ -88,6 +172,8 @@ export function createAskableTextSelectionCapture( const debounce = options.debounce ?? DEFAULT_DEBOUNCE; const once = options.once ?? false; const dedupe = options.dedupe ?? true; + const theme = resolveTextSelectionTheme(options.theme); + const selectionAffordance = resolveSelectionAffordance(options.selectionAffordance); let active = false; let timer: ReturnType | null = null; let lastInteraction: LastInteraction = { gesture: 'programmatic' }; @@ -124,6 +210,7 @@ export function createAskableTextSelectionCapture( metadata: { kind: 'text-selection', length: selection.text.length, + ...(selection.rects?.length ? { rectCount: selection.rects.length } : {}), ...(selection.pointerType ? { pointerType: selection.pointerType } : {}), }, }, @@ -138,8 +225,9 @@ export function createAskableTextSelectionCapture( }, }); + renderSelectionAffordance(packet, selection); currentOptions.onCapture?.(packet, selection); - if (currentOnce) destroy(); + if (currentOnce) stopListening(); return packet; }; @@ -181,7 +269,11 @@ export function createAskableTextSelectionCapture( ownerDocument.addEventListener('keyup', onKeyUp); } - function destroy() { + const removeAffordance = () => { + ownerDocument.getElementById(AFFORDANCE_ID)?.remove(); + }; + + function stopListening() { clearTimer(); ownerDocument.removeEventListener('selectionchange', onSelectionChange); ownerDocument.removeEventListener('pointerup', onPointerUp); @@ -189,6 +281,11 @@ export function createAskableTextSelectionCapture( active = false; } + function destroy() { + stopListening(); + removeAffordance(); + } + function cancel() { const wasActive = active; destroy(); @@ -199,9 +296,157 @@ export function createAskableTextSelectionCapture( start, captureNow: capture, cancel, + clearSelection: removeAffordance, destroy, isActive: () => active, }; + + function renderSelectionAffordance(packet: WebContextPacket, selection: AskableTextSelectionCaptureSelection) { + if (!selectionAffordance || selectionAffordance.persist === false || !selection.bounds) return; + removeAffordance(); + + const custom = selectionAffordance.render?.(packet, selection); + if (custom instanceof HTMLElement) { + custom.id = custom.id || AFFORDANCE_ID; + custom.setAttribute(AFFORDANCE_ATTR, 'true'); + ownerDocument.body.appendChild(custom); + return; + } + + const rootEl = ownerDocument.createElement('div'); + rootEl.id = AFFORDANCE_ID; + rootEl.setAttribute(AFFORDANCE_ATTR, 'true'); + if (selectionAffordance.className) rootEl.className = selectionAffordance.className; + rootEl.style.cssText = [ + 'position:fixed', + 'inset:0', + 'z-index:2147483645', + 'pointer-events:none', + 'box-sizing:border-box', + ].join(';'); + assignStyles(rootEl, selectionAffordance.style); + + const rects = selection.rects?.length ? selection.rects : [selection.bounds]; + rects.forEach((rect) => rootEl.appendChild(createTextMark(rect))); + if (selectionAffordance.label !== '') { + rootEl.appendChild(createSelectionLabel(selectionAffordance.label ?? 'Selected text', selection.bounds)); + } + + const prompt = resolvePromptOptions(selectionAffordance.prompt); + if (prompt) rootEl.appendChild(createPrompt(prompt, packet, selection)); + + ownerDocument.body.appendChild(rootEl); + } + + function createTextMark(rect: WebContextRect): HTMLSpanElement { + const mark = ownerDocument.createElement('span'); + mark.style.cssText = [ + 'position:fixed', + `left:${Math.max(0, rect.x - 2)}px`, + `top:${Math.max(0, rect.y - 1)}px`, + `width:${Math.max(1, rect.width + 4)}px`, + `height:${Math.max(1, rect.height + 2)}px`, + 'border-radius:7px', + `background:${theme.selectionFill}`, + `outline:1px solid ${theme.selectionOutline}`, + `box-shadow:${theme.selectionShadow}`, + 'pointer-events:none', + ].join(';'); + return mark; + } + + function createSelectionLabel(label: string, bounds: WebContextRect): HTMLSpanElement { + const el = ownerDocument.createElement('span'); + el.textContent = label; + if (selectionAffordance?.labelClassName) el.className = selectionAffordance.labelClassName; + el.style.cssText = [ + 'position:fixed', + `left:${bounds.x}px`, + `top:${Math.max(8, bounds.y - 32)}px`, + 'padding:4px 8px', + 'border-radius:999px', + `background:${theme.promptBackground}`, + `border:1px solid ${theme.promptBorder}`, + `color:${theme.promptText}`, + 'font:600 12px/1.2 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + 'box-shadow:0 8px 20px rgba(15,23,42,0.12)', + 'white-space:nowrap', + 'pointer-events:none', + ].join(';'); + assignStyles(el, selectionAffordance?.labelStyle); + return el; + } + + function createPrompt( + prompt: AskableTextSelectionCapturePromptOptions, + packet: WebContextPacket, + selection: AskableTextSelectionCaptureSelection, + ): HTMLFormElement { + const bounds = selection.bounds; + const form = ownerDocument.createElement('form'); + if (prompt.className) form.className = prompt.className; + const placeAbove = bounds ? bounds.y + bounds.height + 56 > window.innerHeight : false; + form.style.cssText = [ + 'position:fixed', + `left:${bounds ? Math.max(8, Math.min(bounds.x, window.innerWidth - 240)) : 8}px`, + bounds && placeAbove + ? `top:${Math.max(8, bounds.y - 48)}px` + : `top:${bounds ? Math.min(window.innerHeight - 48, bounds.y + bounds.height + 10) : 8}px`, + 'display:flex', + 'align-items:center', + 'gap:6px', + 'min-width:220px', + 'max-width:min(320px, calc(100vw - 24px))', + 'padding:6px', + 'border-radius:999px', + `background:${theme.promptBackground}`, + `border:1px solid ${theme.promptBorder}`, + 'box-shadow:0 14px 34px rgba(15,23,42,0.14)', + 'pointer-events:auto', + ].join(';'); + assignStyles(form, prompt.style); + + const input = ownerDocument.createElement('input'); + input.type = 'text'; + input.placeholder = prompt.placeholder ?? 'Ask about this text...'; + if (prompt.inputClassName) input.className = prompt.inputClassName; + input.style.cssText = [ + 'min-width:0', + 'flex:1', + 'border:0', + 'outline:0', + 'background:transparent', + `color:${theme.promptText}`, + 'font:500 13px/1.2 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + ].join(';'); + assignStyles(input, prompt.inputStyle); + + const button = ownerDocument.createElement('button'); + button.type = 'submit'; + button.textContent = 'Ask'; + button.setAttribute('aria-label', prompt.submitLabel ?? 'Ask about selected text'); + if (prompt.buttonClassName) button.className = prompt.buttonClassName; + button.style.cssText = [ + 'border:0', + 'border-radius:999px', + 'padding:6px 10px', + `background:${theme.promptAccent}`, + 'color:#fff', + 'font:700 12px/1.2 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + 'cursor:pointer', + ].join(';'); + assignStyles(button, prompt.buttonStyle); + + form.append(input, button); + form.addEventListener('submit', (event) => { + event.preventDefault(); + const question = input.value.trim(); + if (!question) return; + prompt.onSubmit?.(question, packet, selection); + input.value = ''; + }); + return form; + } } function resolveDocument(root?: Document | HTMLElement): Document | null { @@ -226,16 +471,30 @@ function readSelection( if (!rangeInsideRoot(range, root)) return null; const bounds = rangeBounds(range); + const rects = rangeRects(range); const selector = selectorForRange(range); return { text, ...(bounds ? { bounds } : {}), + ...(rects.length ? { rects } : {}), ...(selector ? { selector } : {}), ...(pointerType ? { pointerType } : {}), capturedAt: new Date().toISOString(), }; } +function rangeRects(range: Range): WebContextRect[] { + if (typeof range.getClientRects !== 'function') return []; + return Array.from(range.getClientRects()) + .filter((rect) => rect.width > 0 || rect.height > 0) + .map((rect) => ({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + })); +} + function rangeInsideRoot(range: Range, root: Document | HTMLElement): boolean { if (typeof Document !== 'undefined' && root instanceof Document) return true; const start = nodeToElement(range.startContainer); @@ -292,3 +551,31 @@ function selectionSignature(selection: AskableTextSelectionCaptureSelection): st selector: selection.selector, }); } + +function resolveTextSelectionTheme(theme?: Partial): AskableTextSelectionCaptureTheme { + return { + ...ASKABLE_TEXT_SELECTION_CAPTURE_THEME, + ...theme, + }; +} + +function resolveSelectionAffordance( + affordance?: boolean | AskableTextSelectionCaptureAffordanceOptions, +): AskableTextSelectionCaptureAffordanceOptions | null { + if (!affordance) return null; + if (affordance === true) return { persist: true }; + return { persist: affordance.persist ?? true, ...affordance }; +} + +function resolvePromptOptions( + prompt?: boolean | AskableTextSelectionCapturePromptOptions, +): AskableTextSelectionCapturePromptOptions | null { + if (!prompt) return null; + if (prompt === true) return {}; + return prompt; +} + +function assignStyles(element: HTMLElement, styles?: AskableTextSelectionCaptureStyle): void { + if (!styles) return; + Object.assign(element.style, styles); +} diff --git a/packages/react/src/useAskableRegionCapture.ts b/packages/react/src/useAskableRegionCapture.ts index 5a4cf68..d076d71 100644 --- a/packages/react/src/useAskableRegionCapture.ts +++ b/packages/react/src/useAskableRegionCapture.ts @@ -20,6 +20,7 @@ export interface UseAskableRegionCaptureResult { lastSelection: AskableRegionCaptureSelection | null; start: (overrides?: Partial) => void; cancel: () => void; + clearSelection: () => void; destroy: () => void; isActive: () => boolean; } @@ -50,6 +51,10 @@ export function useAskableRegionCapture( setActive(false); }, []); + const clearSelection = useCallback(() => { + handleRef.current?.clearSelection(); + }, []); + const start = useCallback((overrides?: Partial) => { handleRef.current?.destroy(); @@ -66,7 +71,6 @@ export function useAskableRegionCapture( if (currentOptions.once === false) { setActive(true); } else { - handleRef.current = null; setActive(false); } // Always read from the ref so a callback that changed since start() @@ -95,6 +99,7 @@ export function useAskableRegionCapture( lastSelection, start, cancel, + clearSelection, destroy, isActive, }; diff --git a/packages/react/src/useAskableTextSelectionCapture.ts b/packages/react/src/useAskableTextSelectionCapture.ts index 4d89fdf..0e02b88 100644 --- a/packages/react/src/useAskableTextSelectionCapture.ts +++ b/packages/react/src/useAskableTextSelectionCapture.ts @@ -21,6 +21,7 @@ export interface UseAskableTextSelectionCaptureResult { start: (overrides?: Partial) => void; captureNow: (overrides?: Partial) => WebContextPacket | null; cancel: () => void; + clearSelection: () => void; destroy: () => void; isActive: () => boolean; } @@ -51,6 +52,10 @@ export function useAskableTextSelectionCapture( setActive(false); }, []); + const clearSelection = useCallback(() => { + handleRef.current?.clearSelection(); + }, []); + const ensureHandle = useCallback((overrides?: Partial) => { const currentOptions = { ...optionsRef.current, @@ -64,7 +69,6 @@ export function useAskableTextSelectionCapture( setLastPacket(packet); setLastSelection(selection); if (currentOptions.once) { - handleRef.current = null; setActive(false); } currentOptions.onCapture?.(packet, selection); @@ -90,7 +94,6 @@ export function useAskableTextSelectionCapture( const handle = handleRef.current ?? ensureHandle(overrides); const packet = handle.captureNow(overrides); if (packet && (optionsRef.current.once || overrides?.once)) { - handleRef.current = null; setActive(false); } return packet; @@ -108,6 +111,7 @@ export function useAskableTextSelectionCapture( start, captureNow, cancel, + clearSelection, destroy, isActive, }; diff --git a/packages/svelte/src/askable.ts b/packages/svelte/src/askable.ts index 4ca1ef0..8c967af 100644 --- a/packages/svelte/src/askable.ts +++ b/packages/svelte/src/askable.ts @@ -46,6 +46,7 @@ export interface AskableRegionCaptureStore { ctx: AskableContext; start: (overrides?: Partial) => void; cancel: () => void; + clearSelection: () => void; destroy: () => void; isActive: () => boolean; } @@ -60,6 +61,7 @@ export interface AskableTextSelectionCaptureStore { start: (overrides?: Partial) => void; captureNow: (overrides?: Partial) => WebContextPacket | null; cancel: () => void; + clearSelection: () => void; destroy: () => void; isActive: () => boolean; } @@ -220,7 +222,6 @@ export function createAskableRegionCaptureStore( if (currentOptions.once === false) { _active.set(true); } else { - handle = null; _active.set(false); } currentOptions.onCapture?.(packet, selection); @@ -242,6 +243,10 @@ export function createAskableRegionCaptureStore( _active.set(false); } + function clearSelection() { + handle?.clearSelection(); + } + function destroy() { destroyCapture(); askable.destroy(); @@ -258,6 +263,7 @@ export function createAskableRegionCaptureStore( ctx: askable.ctx, start, cancel, + clearSelection, destroy, isActive, }; @@ -293,7 +299,6 @@ export function createAskableTextSelectionCaptureStore( _lastPacket.set(packet); _lastSelection.set(selection); if (currentOptions.once) { - handle = null; _active.set(false); } currentOptions.onCapture?.(packet, selection); @@ -318,7 +323,6 @@ export function createAskableTextSelectionCaptureStore( const current = handle ?? ensureHandle(overrides); const packet = current.captureNow(overrides); if (packet && (selectionOptions.once || overrides?.once)) { - handle = null; _active.set(false); } return packet; @@ -330,6 +334,10 @@ export function createAskableTextSelectionCaptureStore( _active.set(false); } + function clearSelection() { + handle?.clearSelection(); + } + function destroy() { destroyCapture(); askable.destroy(); @@ -347,6 +355,7 @@ export function createAskableTextSelectionCaptureStore( start, captureNow, cancel, + clearSelection, destroy, isActive, }; diff --git a/packages/vue/src/useAskableRegionCapture.ts b/packages/vue/src/useAskableRegionCapture.ts index f699db1..d932a5f 100644 --- a/packages/vue/src/useAskableRegionCapture.ts +++ b/packages/vue/src/useAskableRegionCapture.ts @@ -20,6 +20,7 @@ export interface UseAskableRegionCaptureResult { lastSelection: ReturnType>; start: (overrides?: Partial) => void; cancel: () => void; + clearSelection: () => void; destroy: () => void; isActive: () => boolean; } @@ -45,6 +46,10 @@ export function useAskableRegionCapture( active.value = false; } + function clearSelection() { + handle?.clearSelection(); + } + function start(overrides?: Partial) { handle?.destroy(); @@ -61,7 +66,6 @@ export function useAskableRegionCapture( if (currentOptions.once === false) { active.value = true; } else { - handle = null; active.value = false; } currentOptions.onCapture?.(packet, selection); @@ -90,6 +94,7 @@ export function useAskableRegionCapture( lastSelection, start, cancel, + clearSelection, destroy, isActive, }; diff --git a/packages/vue/src/useAskableTextSelectionCapture.ts b/packages/vue/src/useAskableTextSelectionCapture.ts index 94fac60..779497d 100644 --- a/packages/vue/src/useAskableTextSelectionCapture.ts +++ b/packages/vue/src/useAskableTextSelectionCapture.ts @@ -21,6 +21,7 @@ export interface UseAskableTextSelectionCaptureResult { start: (overrides?: Partial) => void; captureNow: (overrides?: Partial) => WebContextPacket | null; cancel: () => void; + clearSelection: () => void; destroy: () => void; isActive: () => boolean; } @@ -46,6 +47,10 @@ export function useAskableTextSelectionCapture( active.value = false; } + function clearSelection() { + handle?.clearSelection(); + } + function ensureHandle(overrides?: Partial) { handle?.destroy(); @@ -60,7 +65,6 @@ export function useAskableTextSelectionCapture( lastPacket.value = packet; lastSelection.value = selection; if (currentOptions.once) { - handle = null; active.value = false; } currentOptions.onCapture?.(packet, selection); @@ -85,7 +89,6 @@ export function useAskableTextSelectionCapture( const current = handle ?? ensureHandle(overrides); const packet = current.captureNow(overrides); if (packet && (options.once || overrides?.once)) { - handle = null; active.value = false; } return packet; @@ -105,6 +108,7 @@ export function useAskableTextSelectionCapture( start, captureNow, cancel, + clearSelection, destroy, isActive, }; diff --git a/site/docs/api/core.md b/site/docs/api/core.md index be31dd0..533489c 100644 --- a/site/docs/api/core.md +++ b/site/docs/api/core.md @@ -801,7 +801,11 @@ Listens for highlighted browser text or reads the current selection on demand, then emits a structured Context packet with explicit consent metadata. ```ts -import { createAskableContext, createAskableTextSelectionCapture } from '@askable-ui/core'; +import { + ASKABLE_TEXT_SELECTION_CAPTURE_THEME, + createAskableContext, + createAskableTextSelectionCapture, +} from '@askable-ui/core'; const ctx = createAskableContext({ viewport: true }); ctx.observe(document); @@ -809,6 +813,19 @@ ctx.observe(document); const selection = createAskableTextSelectionCapture(ctx, { intent: 'answer using this highlighted text', includeViewport: true, + selectionAffordance: { + label: 'Selected text', + prompt: { + placeholder: 'Ask about this text...', + onSubmit(question, packet) { + sendToAgent({ question, context: packet }); + }, + }, + }, + theme: { + ...ASKABLE_TEXT_SELECTION_CAPTURE_THEME, + selectionFill: 'rgba(124,58,237,0.14)', + }, onCapture: (packet, selected) => { sendToAgent(packet); console.log(selected.text); @@ -828,12 +845,24 @@ selection.captureNow(); | `debounce` | `number` | `120` | Delay for `selectionchange` captures | | `once` | `boolean` | `false` | Stop listening after the first accepted capture | | `dedupe` | `boolean` | `true` | Ignore repeated captures of the same text/bounds | +| `theme` | `Partial` | `ASKABLE_TEXT_SELECTION_CAPTURE_THEME` | Selected-text mark and anchored prompt styling | +| `selectionAffordance` | `boolean \| AskableTextSelectionCaptureAffordanceOptions` | `false` | Keep highlighted text visible after capture, optionally with an anchored prompt | | `onCapture` | `(packet, selection) => void` | — | Called with the Context packet and selected text details | | `onCancel` | `() => void` | — | Called when active capture is cancelled | | _...most `AskableContextPacketOptions`_ | | | Passed through to `toContextPacket()` | +`selectionAffordance` is opt-in. Pass `true` to persist highlighted text marks, +or pass an object with `className`, `style`, `label`, `prompt`, and `render()` +hooks. `prompt.onSubmit(question, packet, selection)` lets a highlighted range +immediately anchor a follow-up chat question. + +When browser range geometry is available, the selection includes aggregate +`bounds` plus `rects` for multi-line selected text. Packets include +`target.metadata.rectCount` when rects are present. + **Returns:** `AskableTextSelectionCaptureHandle` — object with `start()`, -`captureNow()`, `cancel()`, `destroy()`, and `isActive()` methods. +`captureNow()`, `cancel()`, `clearSelection()`, `destroy()`, and `isActive()` +methods. --- diff --git a/site/docs/guide/context.md b/site/docs/guide/context.md index 89288c1..235db14 100644 --- a/site/docs/guide/context.md +++ b/site/docs/guide/context.md @@ -224,7 +224,11 @@ Use text selection capture when the user highlights copy in the page and wants that exact selected range sent to an agent: ```ts -import { createAskableContext, createAskableTextSelectionCapture } from '@askable-ui/core'; +import { + ASKABLE_TEXT_SELECTION_CAPTURE_THEME, + createAskableContext, + createAskableTextSelectionCapture, +} from '@askable-ui/core'; const ctx = createAskableContext({ viewport: true }); ctx.observe(document); @@ -232,6 +236,19 @@ ctx.observe(document); const selection = createAskableTextSelectionCapture(ctx, { intent: 'answer using the highlighted text', includeViewport: true, + selectionAffordance: { + label: 'Selected text', + prompt: { + placeholder: 'Ask about this text...', + onSubmit(question, packet) { + sendToAgent({ question, context: packet }); + }, + }, + }, + theme: { + ...ASKABLE_TEXT_SELECTION_CAPTURE_THEME, + selectionFill: 'rgba(124,58,237,0.14)', + }, onCapture: (packet) => { sendToAgent(packet); }, @@ -242,6 +259,8 @@ selection.start(); The resulting packet uses `capture.mode` of `text-selection`, sets `privacy.consent` to `explicit`, and places the selected text on `target.text`. +Set `selectionAffordance` when highlighted text should stay visually marked +after capture or expose a small question input anchored to the selection. Framework wrappers expose the same behavior: diff --git a/site/docs/guide/react.md b/site/docs/guide/react.md index 6c18d83..2dcec5d 100644 --- a/site/docs/guide/react.md +++ b/site/docs/guide/react.md @@ -223,6 +223,15 @@ function TextSelectionTools() { const selection = useAskableTextSelectionCapture({ includeViewport: true, intent: 'answer using the highlighted text', + selectionAffordance: { + label: 'Selected text', + prompt: { + placeholder: 'Ask about this text...', + onSubmit(question, packet) { + sendToAgent({ question, context: packet }); + }, + }, + }, onCapture(packet) { sendToAgent(packet); }, @@ -239,7 +248,9 @@ function TextSelectionTools() { ``` Selection packets use `capture.mode: 'text-selection'` and include the -highlighted text in `target.text`. +highlighted text in `target.text`. Use `selectionAffordance` to keep the +highlight visible after capture and optionally attach a small prompt input to +the selected range. ## History-aware context diff --git a/site/www/index.html b/site/www/index.html index 0be08d6..8fddea0 100644 --- a/site/www/index.html +++ b/site/www/index.html @@ -478,6 +478,10 @@ background: var(--ink); color: #fff; padding: .36rem .62rem; font-size: .7rem; font-weight: 800; cursor: pointer; } + .text-selection-inline-prompt { + z-index: 14; + box-shadow: 0 16px 42px rgba(91,33,182,0.16); + } @keyframes selectedRegionIn { from { opacity: 0; transform: scale(.985); } to { opacity: 1; transform: scale(1); } @@ -1406,7 +1410,7 @@

Start with one attribute.

var patternPayload = document.getElementById('pattern-payload'); var cancelTool = document.getElementById('cancel-tool'); var activeEl = null; var typingTimer = null; var lastKey = null; - var activeTool = null; var drawingEl = null; var selectedRegionEl = null; var dragStart = null; var lassoPoints = []; var toolClearTimer = null; var textSelectionMode = false; + var activeTool = null; var drawingEl = null; var selectedRegionEl = null; var selectedTextPromptEl = null; var dragStart = null; var lassoPoints = []; var toolClearTimer = null; var textSelectionMode = false; var aiTrailPoints = []; var aiTrailFrame = null; var aiTrailDpr = 1; var aiTrailMaxPoints = 22; var dashboard = document.querySelector('.dashboard'); var promptOpts = { format: 'natural', includeText: true }; @@ -1712,6 +1716,7 @@

Start with one attribute.

if (!value) return; chatInput.value = value; send(); + if (input) input.value = ''; }); return form; } @@ -2006,6 +2011,7 @@

Start with one attribute.

composerSuggestions.innerHTML = ''; } function clearSelectedTextPreview() { + if (selectedTextPromptEl) { selectedTextPromptEl.remove(); selectedTextPromptEl = null; } if (selectedTextCard) selectedTextCard.style.display = 'none'; if (selectedTextPreview) selectedTextPreview.textContent = ''; if (contextBar) contextBar.classList.remove('text-context'); @@ -2023,8 +2029,10 @@

Start with one attribute.

if (!dashboard || !range) return; document.querySelectorAll('.text-capture-mark').forEach(function(mark) { mark.remove(); }); var hostRect = dashboard.getBoundingClientRect(); + var lastRect = null; Array.from(range.getClientRects()).forEach(function(rect) { if (rect.width < 3 || rect.height < 3) return; + lastRect = rect; var mark = document.createElement('span'); mark.className = 'text-capture-mark'; mark.style.left = Math.max(0, rect.left - hostRect.left - 2) + 'px'; @@ -2033,6 +2041,37 @@

Start with one attribute.

mark.style.height = rect.height + 2 + 'px'; dashboard.appendChild(mark); }); + if (lastRect) showTextInlinePrompt(lastRect, hostRect); + } + function showTextInlinePrompt(rect, hostRect) { + if (!dashboard) return; + if (selectedTextPromptEl) selectedTextPromptEl.remove(); + selectedTextPromptEl = document.createElement('form'); + selectedTextPromptEl.className = 'selection-inline-prompt text-selection-inline-prompt'; + var left = Math.max(8, Math.min(rect.left - hostRect.left, hostRect.width - 330)); + var top = rect.bottom - hostRect.top + 9; + selectedTextPromptEl.style.left = left + 'px'; + selectedTextPromptEl.style.top = top + 'px'; + if (top + 48 > hostRect.height) { + selectedTextPromptEl.classList.add('above'); + selectedTextPromptEl.style.top = 'auto'; + selectedTextPromptEl.style.bottom = Math.max(8, hostRect.bottom - rect.top + 9) + 'px'; + } + selectedTextPromptEl.innerHTML = ''; + selectedTextPromptEl.addEventListener('pointerdown', function(e) { e.stopPropagation(); }); + selectedTextPromptEl.addEventListener('mousedown', function(e) { e.stopPropagation(); }); + selectedTextPromptEl.addEventListener('mouseup', function(e) { e.stopPropagation(); }); + selectedTextPromptEl.addEventListener('click', function(e) { e.stopPropagation(); }); + selectedTextPromptEl.addEventListener('submit', function(e) { + e.preventDefault(); + var input = selectedTextPromptEl ? selectedTextPromptEl.querySelector('input') : null; + var value = input ? input.value.trim() : ''; + if (!value) return; + chatInput.value = value; + send(); + if (input) input.value = ''; + }); + dashboard.appendChild(selectedTextPromptEl); } function addContextMsg(focus) { var meta = focus ? focus.meta : null;