# 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`. If you use Vanilla, replace `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.


## Choose the target source [#choose-the-target-source]

Use the method that matches where the prompt target comes from:

| Need                                                 | Method                                                |
| ---------------------------------------------------- | ----------------------------------------------------- |
| Preserve selected context after focus moves          | `selection.capture()` and `selection.restore()`       |
| Read the live caret or selection                     | `selection.current()` or `selection.observe()`        |
| Select an explicit `SelectionTarget`                 | `selection.apply(selectionTarget)`                    |
| Position UI from the live selection                  | `selection.getAnchorRect()` or `selection.getRects()` |
| Position UI from a capture or query result           | `viewport.getRect({ target })`                        |
| Identify supported document entities under a pointer | `viewport.entityAt({ x, y })`                         |

`entityAt()` identifies comments, tracked changes, content controls, and citations. It does not return an arbitrary text
position. Use [Context menu](/editor/custom-ui/context-menus) for a complete pointer and keyboard interaction.

`selection.apply()` takes a `SelectionTarget` — the explicit start/end form. `selection.current()` carries both shapes:
`selectionTarget` is that form, and `target` is a `TextTarget` for geometry. Either works for `viewport.getRect()`, which
is why the capture above falls back with `selectionTarget ?? target`, but only `selectionTarget` is accepted by
`apply()`. Read it directly and handle `null` rather than reusing that geometry fallback.

## Resolve entities under a point [#resolve-entities-under-a-point]

`ui.viewport.entityAt()` answers the opposite question: which document entities are painted under a screen point. Coordinates are `MouseEvent` `clientX` and `clientY` space, so a pointer handler can pass its own event through:

```ts
editorShell.addEventListener('pointerdown', (event) => {
  const hits = ui.viewport.entityAt({ x: event.clientX, y: event.clientY });
  const control = hits.find((hit) => hit.type === 'contentControl');
  if (control) console.info('content control under pointer', control.id);
});
```

It returns a `ViewportEntityHit[]` ordered innermost first. Every hit carries a `type` and an `id`. Content-control hits can also carry their `tag` and `scope`.

The hits are tracked changes, comments, content controls, and citations. A point over ordinary text carries none of those, so an empty array is the normal answer, not an error. Branch on what you find; do not treat `[]` as a failed lookup.

For citation cards, attach the listener to the public host and match the hit against the citation list. The hit id is the same id as the matching list item. Treat it as opaque and compare it only for equality:

```ts
const host = ui.viewport.getHost();

const onClick = async (event: MouseEvent) => {
  const hit = ui.viewport.entityAt({ x: event.clientX, y: event.clientY }).find((entity) => entity.type === 'citation');
  if (!hit) return;

  const citations = await Promise.resolve(superdoc.activeEditor?.doc.citations.list());
  const citation = citations?.items.find((item) => item.id === hit.id);
  if (citation) openCitationCard(citation);
};

host?.addEventListener('click', onClick);
// When this custom UI unmounts:
host?.removeEventListener('click', onClick);
```

Do not recover citation identity with `event.target.closest(...)`; the painter DOM is not a public API.

Anchored metadata uses a hidden content control rather than its own hit type, so its record ID arrives as the content-control hit's `tag`. A record with that ID existing does not prove the pointer hit its anchor: an ordinary control can carry a colliding tag. Compare the hit control's `selectionTarget` with `doc.metadata.resolve({ id: hit.tag })` before treating the hit as application metadata.

That comparison narrows the risk without settling it. Content-control hits carry no story, both lookups resolve against the main document part, and painted ids are unique only within that part, so a control in a header, footer, note, or textbox that reuses a body anchor's id and tag passes every check. For documents your own application produced the collision is unlikely; for externally authored files, treat a match as unverified and confirm through your own records. See [Store application data in DOCX](/document-api/application-data).

`story` is not a general field on a hit. Only tracked-change hits carry it, and only when the change is painted outside the body, in a footnote, endnote, header, footer, or textbox. It exists because one tracked-change id can repeat across stories, so `story` names the occurrence actually under the point. Comment and content-control hits are returned normally in those stories but carry no `story`, so do not write story-sensitive handling for them. Use `ui.trackChanges.getAt()` when you need the full tracked-change row rather than the hit.

Pass the object form. The legacy positional form `entityAt(x, y)` fails closed and returns `null`, because the addresses it produced are not resolvable by `getRect()`.

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