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

Choose the target source

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

NeedMethod
Preserve selected context after focus movesselection.capture() and selection.restore()
Read the live caret or selectionselection.current() or selection.observe()
Select an explicit SelectionTargetselection.apply(selectionTarget)
Position UI from the live selectionselection.getAnchorRect() or selection.getRects()
Position UI from a capture or query resultviewport.getRect({ target })
Identify supported document entities under a pointerviewport.entityAt({ x, y })

entityAt() identifies comments, tracked changes, content controls, and citations. It does not return an arbitrary text position. Use Context menu 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

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:

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:

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.

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

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.

On this page