# Build an AI prompt menu for selected text

> Capture selected document text, place an application-owned AI prompt beside it, and keep the target when focus moves.



Show a small **Ask AI** action beside selected text, then expand it into a prompt that keeps the same document range as
context.

## Try the selection prompt [#try-the-selection-prompt]

1. Select the liability cap on the first page. A small **Ask AI** action appears above it.
2. Choose **Ask AI**. The action expands into a question field without losing the captured context.
3. Enter `What does this limit?`, then choose **Ask**.
4. Scroll the document or change its zoom. The prompt follows the selected text while it is visible.
5. Choose **Show selection** to reapply the captured range in the document.

> **Live example: ask AI about selected document text**
>
> Select text in the two-page DOCX. A compact Ask AI action appears first, then expands into a question field. The application captures the text and document target before focus moves. The demo response is local: no text is sent to a model. Scrolling or zooming resolves the same target again, and Show selection reapplies it in the Editor.


The demo creates its response locally and sends no text to a model. In your application, send the question and captured
text through your own server-side model integration.

## Build the same interaction [#build-the-same-interaction]

Start from [Custom UI setup](/editor/custom-ui/controller-setup). Copy this sample to your application's `public`
directory as `contract.docx`, or use another DOCX with selectable text.

[Download the selection sample](/fixtures/custom-selection-workflow.docx): Two pages of selectable contract text · DOCX


### Add the prompt [#add-the-prompt]

Vanilla places the application-owned prompt inside a positioned shell with the Editor. React renders that shell and
prompt in the component below and keeps the quickstart `index.html`. For Vanilla, replace the contents of `<body>` in
`index.html` with:

```html
<div id="toolbar"></div>
<div id="editor-shell" style="position: relative">
  <div id="editor" style="height: 70vh; overflow: auto"></div>
  <aside aria-label="Actions for selected text" data-mode="actions" id="prompt-card" hidden style="position: absolute; z-index: 2">
    <div id="selection-actions">
      <button aria-controls="selection-composer" aria-expanded="false" aria-haspopup="dialog" id="open-selection-prompt" type="button">Ask AI</button>
    </div>
    <div aria-label="Ask AI about selected text" id="selection-composer" hidden role="dialog" style="width: min(19rem, calc(100% - 1rem))">
      <strong>Ask about this</strong>
      <button aria-label="Close AI prompt" id="close-selection-prompt" type="button">Close</button>
      <p id="selection-preview"></p>
      <form id="selection-prompt">
        <label for="selection-question">Ask about this selection</label>
        <textarea id="selection-question" placeholder="What does this limit?" rows="2" style="resize: none"></textarea>
        <button id="ask-selection" disabled type="submit">Ask</button>
      </form>
      <div id="prompt-response" hidden>
        <strong>Response</strong>
        <p id="prompt-answer"></p>
      </div>
      <button id="show-selection" type="button">Show selection</button>
    </div>
  </aside>
</div>

<output id="selection-status" aria-live="polite">Select text in the document.</output>

<script type="module" src="/src/main.ts"></script>

```

### Capture its context and position [#capture-its-context-and-position]

Replace the setup guide's Editor code:

**Vanilla — `src/main.ts`**

```ts
import { SuperDoc } from 'superdoc';
import type { UIConfig } from 'superdoc';
import type { SelectionCapture, SelectionSlice } from 'superdoc/ui';
import 'superdoc/style.css';

type SelectionPromptRequest = Readonly<{
  context: string;
  question: string;
}>;

type SelectionPromptResponse = Readonly<{
  answer: string;
}>;

const editorShell = document.querySelector<HTMLDivElement>('#editor-shell');
const editor = document.querySelector<HTMLDivElement>('#editor');
const promptCard = document.querySelector<HTMLElement>('#prompt-card');
const selectionActions = document.querySelector<HTMLDivElement>('#selection-actions');
const openPromptButton = document.querySelector<HTMLButtonElement>('#open-selection-prompt');
const composer = document.querySelector<HTMLDivElement>('#selection-composer');
const closePromptButton = document.querySelector<HTMLButtonElement>('#close-selection-prompt');
const preview = document.querySelector<HTMLParagraphElement>('#selection-preview');
const form = document.querySelector<HTMLFormElement>('#selection-prompt');
const question = document.querySelector<HTMLTextAreaElement>('#selection-question');
const askButton = document.querySelector<HTMLButtonElement>('#ask-selection');
const response = document.querySelector<HTMLDivElement>('#prompt-response');
const answer = document.querySelector<HTMLParagraphElement>('#prompt-answer');
const showSelectionButton = document.querySelector<HTMLButtonElement>('#show-selection');
const status = document.querySelector<HTMLOutputElement>('#selection-status');

// Set only when the card is hidden while it owns focus, so unhiding restores focus to the
// reader who had it and never steals it from the Editor on the card's first appearance.
let restorePromptFocus: HTMLElement | null = null;

if (
  !editorShell ||
  !editor ||
  !promptCard ||
  !selectionActions ||
  !openPromptButton ||
  !composer ||
  !closePromptButton ||
  !preview ||
  !form ||
  !question ||
  !askButton ||
  !response ||
  !answer ||
  !showSelectionButton ||
  !status
) {
  throw new Error('The selection prompt UI is incomplete.');
}

let capture: SelectionCapture | null = null;
let capturedTargetKey = '';
let promptRequestId = 0;
let isComposerOpen = false;
let stopSelection: (() => void) | null = null;
let stopViewport: (() => void) | null = null;
let removeHandlers: (() => void) | null = null;
let interactionStatus = 'Select text in the document.';

const reportInteraction = (message: string) => {
  interactionStatus = message;
  status.textContent = message;
};

const setComposerOpen = (open: boolean) => {
  isComposerOpen = open;
  selectionActions.hidden = open;
  composer.hidden = !open;
  promptCard.dataset.mode = open ? 'composer' : 'actions';
  promptCard.setAttribute('aria-label', open ? 'Ask AI about selected text' : 'Actions for selected text');
  openPromptButton.setAttribute('aria-expanded', String(open));
};

const resetComposer = () => {
  promptRequestId += 1;
  question.value = '';
  answer.textContent = '';
  response.hidden = true;
  askButton.disabled = true;
  setComposerOpen(false);
};

const readModelResponse = (value: unknown): SelectionPromptResponse => {
  if (typeof value !== 'object' || value === null || !('answer' in value) || typeof value.answer !== 'string') {
    throw new Error('The model endpoint returned an invalid response.');
  }
  return { answer: value.answer };
};

const askModel = async (request: SelectionPromptRequest): Promise<SelectionPromptResponse> => {
  const modelResponse = await fetch('/api/selection-prompt', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(request),
  });
  if (!modelResponse.ok) throw new Error(`The model request failed with status ${modelResponse.status}.`);
  return readModelResponse(await modelResponse.json());
};

const editorUi = {
  comments: false,
  toolbar: { container: '#toolbar' },
} satisfies UIConfig;

// Restoration must not outrank a deliberate focus move. Hiding the card unmounts the focused
// control, so the browser parks focus on <body>; anything else holding it means the reader
// moved on, and keeping their place matters more than returning to the prompt.
function focusIsUnclaimed() {
  const active = document.activeElement;
  return active === null || active === document.body;
}

function focusedPromptControl(card: HTMLElement): HTMLElement | null {
  if (!(document.activeElement instanceof HTMLElement)) return null;
  return card.contains(document.activeElement) ? document.activeElement : null;
}

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  ui: editorUi,
  onReady: ({ superdoc: readySuperDoc }) => {
    const ui = readySuperDoc.ui;

    const positionPrompt = () => {
      const target = capture?.selectionTarget ?? capture?.target;
      if (!target) {
        // Capture only on the visible -> hidden transition. A second invalidation while the
        // card is already hidden would otherwise see focus on the body and clear ownership.
        if (!promptCard.hidden) restorePromptFocus = focusedPromptControl(promptCard);
        promptCard.hidden = true;
        return;
      }

      const geometry = ui.viewport.getRect({ target, relativeTo: editorShell });
      if (!geometry.found || !geometry.rect) {
        // Capture only on the visible -> hidden transition. A second invalidation while the
        // card is already hidden would otherwise see focus on the body and clear ownership.
        if (!promptCard.hidden) restorePromptFocus = focusedPromptControl(promptCard);
        promptCard.hidden = true;
        status.textContent = geometry.reason ?? 'The selection is not currently painted.';
        return;
      }

      const shellBounds = editorShell.getBoundingClientRect();
      const editorBounds = editor.getBoundingClientRect();
      const visibleTop = editorBounds.top - shellBounds.top;
      const visibleBottom = editorBounds.bottom - shellBounds.top;
      const visibleLeft = editorBounds.left - shellBounds.left;
      const visibleRight = editorBounds.right - shellBounds.left;
      const anchorRect = geometry.rects.find(
        (rect) =>
          rect.bottom >= visibleTop &&
          rect.top <= visibleBottom &&
          rect.right >= visibleLeft &&
          rect.left <= visibleRight,
      );
      if (!anchorRect) {
        // Capture only on the visible -> hidden transition. A second invalidation while the
        // card is already hidden would otherwise see focus on the body and clear ownership.
        if (!promptCard.hidden) restorePromptFocus = focusedPromptControl(promptCard);
        promptCard.hidden = true;
        status.textContent = 'Scroll back to the selection to show its prompt.';
        return;
      }

      // Hiding the card removes the focused control from the rendered tree, so focus falls to
      // the document body. Restore it when the range scrolls back into view, or a keyboard
      // user has to rediscover the prompt.
      promptCard.hidden = false;
      if (restorePromptFocus) {
        // The card is only hidden, never removed, so the exact control the reader was on is
        // still focusable; returning them to the textarea would undo the navigation they did.
        const control = restorePromptFocus;
        restorePromptFocus = null;
        if (focusIsUnclaimed()) {
          (control.isConnected ? control : composer.hidden ? openPromptButton : question).focus();
        }
      }
      status.textContent = interactionStatus;
      const edge = 8;
      const gap = 12;
      const maxLeft = Math.max(edge, editorShell.clientWidth - promptCard.offsetWidth - edge);
      const minTop = editorBounds.top - shellBounds.top + edge;
      const maxTop = Math.max(minTop, editorBounds.bottom - shellBounds.top - promptCard.offsetHeight - edge);
      const centeredLeft = anchorRect.left + anchorRect.width / 2 - promptCard.offsetWidth / 2;
      const above = anchorRect.top - promptCard.offsetHeight - gap;
      const below = anchorRect.bottom + gap;
      const belowFits = below + promptCard.offsetHeight <= visibleBottom - edge;
      const preferredTop = isComposerOpen && belowFits ? below : above >= minTop ? above : below;
      promptCard.style.left = `${Math.max(edge, Math.min(centeredLeft, maxLeft))}px`;
      promptCard.style.top = `${Math.max(minTop, Math.min(preferredTop, maxTop))}px`;
    };

    const renderSelection = (selection: SelectionSlice) => {
      if (selection.status !== 'ready' || selection.empty) return;

      const nextCapture = ui.selection.capture();
      if (!nextCapture) return;

      const nextTargetKey = JSON.stringify([nextCapture.selectionTarget ?? nextCapture.target, nextCapture.quotedText]);
      capture = nextCapture;
      if (nextTargetKey !== capturedTargetKey) {
        capturedTargetKey = nextTargetKey;
        resetComposer();
      }
      preview.textContent = `“${nextCapture.quotedText}”`;
      reportInteraction('Selection captured. Choose Ask AI.');
      positionPrompt();
    };

    const openComposer = () => {
      if (!capture) return;
      setComposerOpen(true);
      reportInteraction('The composer kept the captured text as context.');
      positionPrompt();
      question.focus();
    };

    const closeComposer = () => {
      resetComposer();
      reportInteraction('Selection captured. Choose Ask AI.');
      positionPrompt();
      openPromptButton.focus();
    };

    const showSelection = () => {
      if (!capture) return;
      const result = ui.selection.restore(capture);
      reportInteraction(
        result.success ? 'Selection shown.' : `Could not show selection: ${result.reason ?? 'unknown'}`,
      );
    };

    const updateQuestion = () => {
      promptRequestId += 1;
      answer.textContent = '';
      response.hidden = true;
      askButton.disabled = question.value.trim().length === 0;
      reportInteraction('The prompt kept its captured document context.');
    };

    const submitPrompt = async (event: SubmitEvent) => {
      event.preventDefault();
      const currentCapture = capture;
      const currentQuestion = question.value.trim();
      if (!currentCapture || !currentQuestion) return;

      const requestId = (promptRequestId += 1);
      askButton.disabled = true;
      response.hidden = true;
      reportInteraction('Asking the model about the captured text…');

      try {
        const result = await askModel({ context: currentCapture.quotedText, question: currentQuestion });
        if (requestId !== promptRequestId) return;
        answer.textContent = result.answer;
        response.hidden = false;
        reportInteraction('Response received for the captured text.');
        positionPrompt();
      } catch (error) {
        if (requestId !== promptRequestId) return;
        reportInteraction(error instanceof Error ? error.message : 'The model request failed.');
      } finally {
        if (requestId === promptRequestId) askButton.disabled = question.value.trim().length === 0;
      }
    };

    renderSelection(ui.selection.getSnapshot());
    stopSelection = ui.selection.observe(renderSelection);
    stopViewport = ui.viewport.observe(positionPrompt);
    openPromptButton.addEventListener('click', openComposer);
    closePromptButton.addEventListener('click', closeComposer);
    showSelectionButton.addEventListener('click', showSelection);
    question.addEventListener('input', updateQuestion);
    form.addEventListener('submit', submitPrompt);
    removeHandlers = () => {
      openPromptButton.removeEventListener('click', openComposer);
      closePromptButton.removeEventListener('click', closeComposer);
      showSelectionButton.removeEventListener('click', showSelection);
      question.removeEventListener('input', updateQuestion);
      form.removeEventListener('submit', submitPrompt);
    };
  },
  onContentError: ({ error }) => {
    reportInteraction('The document could not be read.');
    console.error(error);
  },
  onException: ({ error }) => {
    reportInteraction('The editor reported a runtime error.');
    console.error(error);
  },
});

window.addEventListener('beforeunload', () => {
  promptRequestId += 1;
  stopSelection?.();
  stopViewport?.();
  removeHandlers?.();
  superdoc.destroy();
});

```

**React — `src/App.tsx`**

```tsx
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type FormEvent } from 'react';
import { SuperDocEditor } from '@superdoc/react';
import type { UIConfig } from 'superdoc';
import type { SelectionCapture } from 'superdoc/ui';
import { SuperDocUIProvider, useSetSuperDoc, useSuperDocSelection, useSuperDocUI } from 'superdoc/ui/react';
import '@superdoc/react/style.css';

type PromptPosition = { left: number; top: number };
type SelectionPromptRequest = Readonly<{ context: string; question: string }>;
type SelectionPromptResponse = Readonly<{ answer: string }>;

const editorUi = { comments: false } satisfies UIConfig;

// Restoration must not outrank a deliberate focus move. Hiding the card unmounts the focused
// control, so the browser parks focus on <body>; anything else holding it means the reader
// moved on, and keeping their place matters more than returning to the prompt.
function focusIsUnclaimed() {
  const active = document.activeElement;
  return active === null || active === document.body;
}

// The card remounts on scroll-back with fresh elements, so the reader's position is recorded
// as a control name rather than a node. Returning them to the textarea when they were on
// Close or Ask would make them navigate back to what they had already reached.
function promptControlName(card: HTMLElement | null): string | null {
  if (!card || !(document.activeElement instanceof HTMLElement)) return null;
  if (!card.contains(document.activeElement)) return null;
  return document.activeElement.closest('[data-prompt-control]')?.getAttribute('data-prompt-control') ?? '';
}

function focusPromptControl(card: HTMLElement | null, name: string | null): boolean {
  if (!card || !name) return false;
  const control = card.querySelector(`[data-prompt-control="${name}"]`);
  if (!(control instanceof HTMLElement)) return false;
  control.focus();
  return true;
}

function readModelResponse(value: unknown): SelectionPromptResponse {
  if (typeof value !== 'object' || value === null || !('answer' in value) || typeof value.answer !== 'string') {
    throw new Error('The model endpoint returned an invalid response.');
  }
  return { answer: value.answer };
}

async function askModel(request: SelectionPromptRequest): Promise<SelectionPromptResponse> {
  const response = await fetch('/api/selection-prompt', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(request),
  });
  if (!response.ok) throw new Error(`The model request failed with status ${response.status}.`);
  return readModelResponse(await response.json());
}

export default function App() {
  return (
    <SuperDocUIProvider>
      <SelectionPromptEditor />
    </SuperDocUIProvider>
  );
}

function SelectionPromptEditor() {
  const ui = useSuperDocUI();
  const selection = useSuperDocSelection();
  const setSuperDoc = useSetSuperDoc();
  const shellRef = useRef<HTMLDivElement>(null);
  const promptRef = useRef<HTMLElement>(null);
  const actionButtonRef = useRef<HTMLButtonElement>(null);
  const questionRef = useRef<HTMLTextAreaElement>(null);
  const captureKeyRef = useRef('');
  const promptRequestIdRef = useRef(0);
  const restoreActionFocusRef = useRef(false);
  // The composer keeps `isComposerOpen` true across a scroll-away, so visibility alone must
  // not refocus it: only opening it, or having owned focus when it was hidden, may.
  const restoreComposerFocusRef = useRef<string | null>(null);
  const wasComposerOpenRef = useRef(false);
  const [capture, setCapture] = useState<SelectionCapture | null>(null);
  const [position, setPosition] = useState<PromptPosition | null>(null);
  const [prompt, setPrompt] = useState('');
  const [answer, setAnswer] = useState('');
  const [isAsking, setIsAsking] = useState(false);
  const [isComposerOpen, setIsComposerOpen] = useState(false);
  const [status, setStatus] = useState('Select text in the document.');
  const [geometryStatus, setGeometryStatus] = useState<string | null>(null);

  const positionPrompt = useCallback(() => {
    const shell = shellRef.current;
    const target = capture?.selectionTarget ?? capture?.target;
    if (!ui || !shell || !target) {
      setPosition(null);
      setGeometryStatus(null);
      return;
    }

    const geometry = ui.viewport.getRect({ target, relativeTo: shell });
    if (!geometry.found || !geometry.rect) {
      setPosition(null);
      setGeometryStatus('The selection is not currently painted.');
      return;
    }

    const host = ui.viewport.getHost();
    if (!host) {
      setPosition(null);
      setGeometryStatus('The editor viewport is not currently available.');
      return;
    }

    const shellBounds = shell.getBoundingClientRect();
    const hostBounds = host.getBoundingClientRect();
    const visibleTop = hostBounds.top - shellBounds.top;
    const visibleBottom = hostBounds.bottom - shellBounds.top;
    const visibleLeft = hostBounds.left - shellBounds.left;
    const visibleRight = hostBounds.right - shellBounds.left;
    const anchorRect = geometry.rects.find(
      (rect) =>
        rect.bottom >= visibleTop &&
        rect.top <= visibleBottom &&
        rect.right >= visibleLeft &&
        rect.left <= visibleRight,
    );
    if (!anchorRect) {
      setPosition(null);
      setGeometryStatus('Scroll back to the selection to show its prompt.');
      return;
    }

    const edge = 8;
    const gap = 12;
    const promptWidth = promptRef.current?.offsetWidth ?? (isComposerOpen ? 304 : 104);
    const promptHeight = promptRef.current?.offsetHeight ?? (isComposerOpen ? 300 : 40);
    const maxLeft = Math.max(edge, shell.clientWidth - promptWidth - edge);
    const minTop = visibleTop + edge;
    const maxTop = Math.max(minTop, visibleBottom - promptHeight - edge);
    const centeredLeft = anchorRect.left + anchorRect.width / 2 - promptWidth / 2;
    const above = anchorRect.top - promptHeight - gap;
    const below = anchorRect.bottom + gap;
    const belowFits = below + promptHeight <= visibleBottom - edge;
    const preferredTop = isComposerOpen && belowFits ? below : above >= minTop ? above : below;

    setGeometryStatus(null);
    setPosition({
      left: Math.max(edge, Math.min(centeredLeft, maxLeft)),
      top: Math.max(minTop, Math.min(preferredTop, maxTop)),
    });
  }, [capture, isComposerOpen, ui]);

  useEffect(() => {
    if (!ui || selection.status !== 'ready' || selection.empty) return;
    const nextCapture = ui.selection.capture();
    if (!nextCapture) return;

    const nextKey = JSON.stringify([nextCapture.selectionTarget ?? nextCapture.target, nextCapture.quotedText]);
    if (nextKey !== captureKeyRef.current) {
      captureKeyRef.current = nextKey;
      promptRequestIdRef.current += 1;
      setPrompt('');
      setAnswer('');
      setIsAsking(false);
      setIsComposerOpen(false);
    }
    setCapture(nextCapture);
    setStatus('Selection captured. Choose Ask AI.');
  }, [selection, ui]);

  const isPromptVisible = position !== null;
  useLayoutEffect(positionPrompt, [answer, isComposerOpen, isPromptVisible, positionPrompt]);
  useEffect(() => ui?.viewport.observe(positionPrompt), [positionPrompt, ui]);

  // Record whether any prompt control — textarea, Close, Ask, Show selection — owned focus
  // just before the card unmounts, so a scroll-back restores it to the reader who had it
  // rather than pulling it from wherever it moved.
  useLayoutEffect(() => {
    if (!isPromptVisible) return undefined;
    return () => {
      restoreComposerFocusRef.current = promptControlName(promptRef.current);
    };
  }, [isPromptVisible]);

  useEffect(() => {
    // The card unmounts whenever the captured range scrolls out of view. isComposerOpen does
    // not change across that, so visibility has to drive focus restoration too.
    const composerJustOpened = isComposerOpen && !wasComposerOpenRef.current;
    wasComposerOpenRef.current = isComposerOpen;
    if (!isPromptVisible) return;
    const restoreTarget = restoreComposerFocusRef.current;
    if (isComposerOpen) {
      if (!composerJustOpened && restoreTarget === null) return;
      restoreComposerFocusRef.current = null;
      if (composerJustOpened) {
        questionRef.current?.focus();
        return;
      }
      if (focusIsUnclaimed() && !focusPromptControl(promptRef.current, restoreTarget)) {
        questionRef.current?.focus();
      }
      return;
    }
    // The capture is card-wide, so the compact action button's ownership lands in the same
    // record; either signal restores this branch. Closing the composer is deliberate and always
    // lands focus; a scroll-back only reclaims focus that nobody else took.
    const closedComposer = restoreActionFocusRef.current;
    if (!closedComposer && restoreTarget === null) return;
    restoreActionFocusRef.current = false;
    restoreComposerFocusRef.current = null;
    if (closedComposer) {
      actionButtonRef.current?.focus();
      return;
    }
    if (focusIsUnclaimed() && !focusPromptControl(promptRef.current, restoreTarget)) {
      actionButtonRef.current?.focus();
    }
  }, [isComposerOpen, isPromptVisible]);

  useEffect(
    () => () => {
      promptRequestIdRef.current += 1;
    },
    [],
  );

  function showSelection() {
    if (!ui || !capture) return;
    const result = ui.selection.restore(capture);
    setStatus(result.success ? 'Selection shown.' : `Could not show selection: ${result.reason ?? 'unknown'}.`);
  }

  function closeComposer() {
    restoreActionFocusRef.current = true;
    promptRequestIdRef.current += 1;
    setIsAsking(false);
    setPrompt('');
    setAnswer('');
    setIsComposerOpen(false);
    setStatus('Selection captured. Choose Ask AI.');
  }

  function openComposer() {
    restoreActionFocusRef.current = false;
    setIsComposerOpen(true);
    setStatus('The composer kept the captured text as context.');
  }

  async function submitPrompt(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const currentCapture = capture;
    const question = prompt.trim();
    if (!currentCapture || !question || isAsking) return;

    const requestId = (promptRequestIdRef.current += 1);
    setIsAsking(true);
    setAnswer('');
    setStatus('Asking the model about the captured text…');

    try {
      const result = await askModel({ context: currentCapture.quotedText, question });
      if (requestId !== promptRequestIdRef.current) return;
      setAnswer(result.answer);
      setStatus('Response received for the captured text.');
    } catch (error) {
      if (requestId !== promptRequestIdRef.current) return;
      setStatus(error instanceof Error ? error.message : 'The model request failed.');
    } finally {
      if (requestId === promptRequestIdRef.current) setIsAsking(false);
    }
  }

  return (
    <>
      <div ref={shellRef} style={{ position: 'relative' }}>
        <SuperDocEditor
          document='/contract.docx'
          onContentError={({ error }) => {
            setGeometryStatus(null);
            setStatus('The document could not be read.');
            console.error(error);
          }}
          onException={({ error }) => {
            setGeometryStatus(null);
            setStatus('The editor reported a runtime error.');
            console.error(error);
          }}
          onReady={({ superdoc }) => setSuperDoc(superdoc)}
          ui={editorUi}
        />

        {capture && position ? (
          <aside
            aria-label={isComposerOpen ? 'Ask AI about selected text' : 'Actions for selected text'}
            ref={promptRef}
            style={{
              left: position.left,
              position: 'absolute',
              top: position.top,
              width: isComposerOpen ? 'min(19rem, calc(100% - 1rem))' : 'max-content',
            }}
            role={isComposerOpen ? 'dialog' : undefined}
          >
            {isComposerOpen ? (
              <>
                <strong>Ask about this</strong>
                <button aria-label='Close AI prompt' data-prompt-control='close' onClick={closeComposer} type='button'>
                  Close
                </button>
                <p>“{capture.quotedText}”</p>
                <form onSubmit={(event) => void submitPrompt(event)}>
                  <label htmlFor='selection-question'>Ask about this selection</label>
                  <textarea
                    data-prompt-control='question'
                    id='selection-question'
                    onChange={(event) => {
                      promptRequestIdRef.current += 1;
                      setPrompt(event.target.value);
                      setAnswer('');
                      setIsAsking(false);
                      setStatus('The prompt kept its captured document context.');
                    }}
                    placeholder='What does this limit?'
                    ref={questionRef}
                    rows={2}
                    style={{ resize: 'none' }}
                    value={prompt}
                  />
                  <button data-prompt-control='ask' disabled={isAsking || !prompt.trim()} type='submit'>
                    {isAsking ? 'Asking…' : 'Ask'}
                  </button>
                </form>
                {answer ? (
                  <div>
                    <strong>Response</strong>
                    <p>{answer}</p>
                  </div>
                ) : null}
                <button data-prompt-control='show' onClick={showSelection} type='button'>
                  Show selection
                </button>
              </>
            ) : (
              <button
                aria-haspopup='dialog'
                data-prompt-control='action'
                onClick={openComposer}
                ref={actionButtonRef}
                type='button'
              >
                Ask AI
              </button>
            )}
          </aside>
        ) : null}
      </div>
      <output aria-live='polite'>{geometryStatus ?? status}</output>
    </>
  );
}

```


`ui.selection.capture()` preserves two things the prompt needs: `quotedText` for model context and a document target
that remains available when the **Ask AI** button and question field take browser focus. The example sends the captured
text, not the live browser selection. A new selection closes the composer and clears its previous question and response.

`ui.viewport.getRect({ target, relativeTo: editorShell })` resolves the target against the current painted layout and
returns coordinates for the prompt's containing block. `ui.viewport.observe()` tells the application to measure again
after scrolling, zooming, resizing, pagination, or repainting changes that geometry. Stop the observer when the custom UI
unmounts.

The example expects this application-owned endpoint:

```http
POST /api/selection-prompt
Content-Type: application/json

{ "context": "the fees Customer paid in the twelve months before the claim", "question": "What does this limit?" }
```

Return a JSON object with an `answer` string. Keep provider credentials and document-access checks on the server.
SuperDoc does not choose a model or send the captured text for you.

> **Keep the target, not its coordinates (warning)**
>
> Do not cache rectangle coordinates as document identity. Coordinates become stale when layout changes. Keep the
> capture and call `getRect()` again.


## Verify the prompt [#verify-the-prompt]

Select text and confirm that only the compact **Ask AI** action appears. Open it and type a question. Confirm that the
request contains the captured `quotedText`, even after the textarea takes focus. Scroll or zoom while the text remains
visible and confirm that the prompt moves with it. Show the selection, then select text on the other page and confirm
that the composer closes and its previous question and response are cleared.

Continue with [Review findings](/editor/custom-ui/review-highlights) when a model result or another application finding
needs durable data anchored to document text.

## Advanced target handling [#advanced-target-handling]

For explicit selections and pointer hit-testing, see the
[Selection and position reference](/editor/custom-ui/selection-reference). It covers target shapes, entity hits,
citation listeners, and story limitations.
