Custom UI

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

  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.
Select text in the document to ask AI about it.

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

Start from Custom UI setup. Copy this sample to your application's public directory as contract.docx, or use another DOCX with selectable text.

Download the selection sampleTwo pages of selectable contract textDOCX

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:

<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

Replace the setup guide's Editor code:

src/main.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();
});

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:

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.

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 when a model result or another application finding needs durable data anchored to document text.

Advanced target handling

For explicit selections and pointer hit-testing, see the Selection and position reference. It covers target shapes, entity hits, citation listeners, and story limitations.

On this page