{"version":3,"file":"use-mention-menu.cjs","sources":["../../../components/prompt-input/use-mention-menu.ts"],"sourcesContent":["'use client';\n\nimport { compareItems, rankItem } from '@tanstack/match-sorter-utils';\nimport type { EditorView } from 'prosemirror-view';\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore\n} from 'react';\nimport {\n  type EditorActions,\n  type SuggestionAnchor,\n  type SuggestionGroup,\n  type SuggestionState,\n  suggestionOptionId\n} from '../editor';\nimport type { PromptInputMention } from './prompt-input-context';\nimport type {\n  PromptInputMentionItem,\n  PromptInputMentionRegistry\n} from './prompt-input-mention-registry';\n\n/** Long enough that a fast typist makes one request per word, not per letter. */\nconst SEARCH_DEBOUNCE_MS = 150;\n\nconst NO_ITEMS: PromptInputMentionItem[] = [];\n\n/**\n * Re-renders whatever reads the registry when a config or an item lands.\n * `useSyncExternalStore` rather than a subscribe-and-bump effect, because\n * `Mentions` registers its trigger from an effect that runs *before* a later\n * sibling `Editor` gets to subscribe — the store re-reads its snapshot after\n * subscribing, so that first registration is never missed.\n */\nexport function useMentionRegistryVersion(\n  registry: PromptInputMentionRegistry\n): void {\n  useSyncExternalStore(\n    registry.subscribe,\n    registry.getRevision,\n    registry.getRevision\n  );\n}\n\n/**\n * Groups render in first-appearance order of `group` within the results, so\n * consumers control section order by ordering their data. Ungrouped items lead\n * in a headerless section, and a group that filters down to nothing disappears.\n */\nexport function toGroups(items: PromptInputMentionItem[]): SuggestionGroup[] {\n  const ungrouped: PromptInputMentionItem[] = [];\n  const order: string[] = [];\n  const buckets = new Map<string, PromptInputMentionItem[]>();\n\n  for (const item of items) {\n    if (!item.group) {\n      ungrouped.push(item);\n      continue;\n    }\n    const bucket = buckets.get(item.group);\n    if (bucket) {\n      bucket.push(item);\n    } else {\n      buckets.set(item.group, [item]);\n      order.push(item.group);\n    }\n  }\n\n  const groups: SuggestionGroup[] = [];\n  if (ungrouped.length) groups.push({ items: ungrouped });\n  for (const label of order) {\n    groups.push({ label, items: buckets.get(label) ?? [] });\n  }\n  return groups;\n}\n\n/** Sync data filtering — match-sorter on the label, best matches first. */\nexport function filterItems(\n  items: PromptInputMentionItem[],\n  query: string\n): PromptInputMentionItem[] {\n  if (!query) return items;\n  const ranked = items\n    .map(item => ({\n      item,\n      ranking: rankItem(item, query, {\n        accessors: [entry => (entry as PromptInputMentionItem).label]\n      })\n    }))\n    .filter(entry => entry.ranking.passed);\n  ranked.sort((a, b) => compareItems(a.ranking, b.ranking));\n  return ranked.map(entry => entry.item);\n}\n\nfunction firstEnabled(items: PromptInputMentionItem[]): number {\n  const index = items.findIndex(item => !item.disabled);\n  return index;\n}\n\nfunction step(\n  items: PromptInputMentionItem[],\n  from: number,\n  direction: 1 | -1\n): number {\n  if (items.length === 0) return -1;\n  let index = from;\n  for (let attempt = 0; attempt < items.length; attempt += 1) {\n    index = (index + direction + items.length) % items.length;\n    if (!items[index]?.disabled) return index;\n  }\n  return -1;\n}\n\nexport interface UseMentionMenuOptions {\n  viewRef: React.RefObject<EditorView | null>;\n  actions: EditorActions;\n  registry: PromptInputMentionRegistry;\n  suggestion: SuggestionState | null;\n  disabled: boolean;\n  listboxId: string;\n}\n\nexport interface UseMentionMenuResult {\n  open: boolean;\n  anchor: SuggestionAnchor;\n  groups: SuggestionGroup[];\n  highlightedIndex: number;\n  setHighlightedIndex: (index: number) => void;\n  loading: boolean;\n  loadingRowCount: number;\n  emptyMessage: React.ReactNode;\n  select: (item: PromptInputMentionItem) => void;\n  close: () => void;\n  activeOptionId: string | undefined;\n  /** Routed from the ProseMirror plugin while a query is active. */\n  handleKeyDown: (event: KeyboardEvent, state: SuggestionState) => boolean;\n}\n\nexport function useMentionMenu({\n  viewRef,\n  actions,\n  registry,\n  suggestion,\n  disabled,\n  listboxId\n}: UseMentionMenuOptions): UseMentionMenuResult {\n  useMentionRegistryVersion(registry);\n\n  const config = suggestion ? registry.get(suggestion.trigger) : undefined;\n  const open = !disabled && suggestion !== null && config !== undefined;\n\n  const suggestionRef = useRef(suggestion);\n  suggestionRef.current = suggestion;\n\n  const [results, setResults] = useState<PromptInputMentionItem[]>(NO_ITEMS);\n  const [loading, setLoading] = useState(false);\n  const [highlightedIndex, setHighlightedIndex] = useState(-1);\n\n  const trigger = suggestion?.trigger;\n  const query = suggestion?.query ?? '';\n  // Depended on individually rather than through `config`, so a changed\n  // `emptyMessage` cannot restart an in-flight search.\n  const search = config?.onSearch;\n  const syncItems = config?.items;\n\n  // Async results: debounced, aborted on supersede, and guarded by a sequence\n  // number so a slow response for an old query can never land.\n  const sequenceRef = useRef(0);\n  const warnedRef = useRef(false);\n\n  useEffect(() => {\n    if (!open || trigger === undefined) {\n      setResults(NO_ITEMS);\n      setLoading(false);\n      return;\n    }\n\n    if (!search) {\n      setLoading(false);\n      setResults(filterItems(syncItems ?? NO_ITEMS, query));\n      return;\n    }\n\n    const sequence = (sequenceRef.current += 1);\n    const controller = new AbortController();\n    setLoading(true);\n\n    const timer = window.setTimeout(() => {\n      search(query, { trigger, signal: controller.signal })\n        .then(items => {\n          if (sequence !== sequenceRef.current) return;\n          setResults(items);\n          setLoading(false);\n        })\n        .catch((error: unknown) => {\n          if (sequence !== sequenceRef.current) return;\n          setLoading(false);\n          setResults(NO_ITEMS);\n          const aborted =\n            controller.signal.aborted ||\n            (error instanceof Error && error.name === 'AbortError');\n          if (\n            !aborted &&\n            !warnedRef.current &&\n            process.env.NODE_ENV !== 'production'\n          ) {\n            warnedRef.current = true;\n            console.warn(\n              '[Apsara] PromptInput.Mentions onSearch rejected; the menu falls ' +\n                'back to its empty state.',\n              error\n            );\n          }\n        });\n    }, SEARCH_DEBOUNCE_MS);\n\n    return () => {\n      window.clearTimeout(timer);\n      controller.abort();\n    };\n  }, [open, trigger, query, search, syncItems]);\n\n  const groups = useMemo(() => toGroups(results), [results]);\n  const flat = useMemo(() => groups.flatMap(group => group.items), [groups]);\n  const flatRef = useRef(flat);\n  flatRef.current = flat;\n\n  // The first enabled row is auto-highlighted whenever the result set changes.\n  // Keyed on what the rows *are* rather than on the array's identity: an inline\n  // `items={[…]}` prop is a fresh array on every consumer render, and resetting\n  // on identity would throw away the user's arrow-key position whenever\n  // something unrelated re-rendered above the composer.\n  const rowSignature = useMemo(\n    () =>\n      flat\n        .map(item => `${item.type ?? ''}:${item.id}:${item.disabled ? 1 : 0}`)\n        .join('\\0'),\n    [flat]\n  );\n  // biome-ignore lint/correctness/useExhaustiveDependencies: the signature is the trigger; the rows are read through a ref\n  useEffect(() => {\n    setHighlightedIndex(firstEnabled(flatRef.current));\n  }, [rowSignature]);\n\n  // Held past the config that supplied it: closing clears the active trigger,\n  // so reading the callback off the live config would swallow the `false`.\n  const onOpenChangeRef = useRef<((open: boolean) => void) | undefined>(\n    undefined\n  );\n  if (config?.onOpenChange) onOpenChangeRef.current = config.onOpenChange;\n  const lastOpenRef = useRef(false);\n  useEffect(() => {\n    if (lastOpenRef.current === open) return;\n    lastOpenRef.current = open;\n    onOpenChangeRef.current?.(open);\n  }, [open]);\n\n  const close = useCallback(() => {\n    actions.dismissSuggestion();\n  }, [actions]);\n\n  // Spaces are allowed while results are non-empty, so multi-word entities stay\n  // filterable. The first keystroke that empties the results while the query\n  // already contains a space gives up and leaves the text literal.\n  useEffect(() => {\n    if (!open || loading) return;\n    if (results.length > 0) return;\n    if (!query.includes(' ')) return;\n    close();\n  }, [open, loading, results.length, query, close]);\n\n  const select = useCallback(\n    (item: PromptInputMentionItem) => {\n      const state = suggestionRef.current;\n      if (!state) return;\n      const type = item.type ?? 'mention';\n      registry.remember(state.trigger, { ...item, type });\n      actions.insertMention(\n        {\n          id: item.id,\n          label: item.label,\n          type,\n          trigger: state.trigger\n        },\n        { from: state.from, to: state.to }\n      );\n    },\n    [actions, registry]\n  );\n\n  // The last rect the caret actually had. Selecting an item takes the query\n  // range out of the document in the same breath as it closes the menu, but the\n  // popup is still animating out and the positioner keeps measuring — without\n  // something to hand back, it would read a zero rect and the closing menu\n  // would jump to the top-left corner of the viewport and flicker there.\n  const lastRectRef = useRef<DOMRect | null>(null);\n\n  const anchor = useMemo<SuggestionAnchor>(\n    () => ({\n      get contextElement() {\n        return viewRef.current?.dom;\n      },\n      getBoundingClientRect: () => {\n        const view = viewRef.current;\n        const state = suggestionRef.current;\n        if (view && state) {\n          try {\n            const coords = view.coordsAtPos(state.from);\n            const rect = new DOMRect(\n              coords.left,\n              coords.top,\n              0,\n              Math.max(0, coords.bottom - coords.top)\n            );\n            lastRectRef.current = rect;\n            return rect;\n          } catch {\n            // The range is gone, or the browser is mid-relayout.\n          }\n        }\n        // jsdom has no geometry to give either, and no animation to cover.\n        return lastRectRef.current ?? new DOMRect(0, 0, 0, 0);\n      }\n    }),\n    [viewRef]\n  );\n\n  const highlightedRef = useRef(highlightedIndex);\n  highlightedRef.current = highlightedIndex;\n  const openRef = useRef(open);\n  openRef.current = open;\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent) => {\n      if (!openRef.current) return false;\n\n      const items = flatRef.current;\n      const current = highlightedRef.current;\n\n      const consume = () => {\n        event.preventDefault();\n        // Enter must not reach the form and Escape must not reach ChatPanel,\n        // Dialog or Drawer — either would destroy the draft.\n        event.stopPropagation();\n      };\n\n      switch (event.key) {\n        case 'ArrowDown':\n          consume();\n          setHighlightedIndex(step(items, current, 1));\n          return true;\n        case 'ArrowUp':\n          consume();\n          setHighlightedIndex(step(items, current, -1));\n          return true;\n        case 'Escape':\n          consume();\n          close();\n          return true;\n        case 'Enter':\n        case 'Tab': {\n          const item = items[current];\n          if (!item || item.disabled) {\n            // Nothing to pick: leave the text literal and let the key through\n            // to submit or to move focus.\n            close();\n            return false;\n          }\n          consume();\n          select(item);\n          return true;\n        }\n        default:\n          return false;\n      }\n    },\n    [close, select]\n  );\n\n  return {\n    open,\n    anchor,\n    groups,\n    highlightedIndex,\n    setHighlightedIndex,\n    loading,\n    loadingRowCount: config?.loadingRowCount ?? 3,\n    emptyMessage: config?.emptyMessage ?? 'No results',\n    select,\n    close,\n    activeOptionId:\n      open && highlightedIndex >= 0\n        ? suggestionOptionId(listboxId, highlightedIndex)\n        : undefined,\n    handleKeyDown\n  };\n}\n\n/**\n * `icon`, `trailing` and `data` cannot survive serialization, so a chip parsed\n * from `defaultValue` starts label-only and fills in when the consumer's\n * `resolveMentions` resolves — the same progressive enhancement `Select.Value`\n * uses when it falls back to the raw value until an item registers. A rejection\n * or a missing item leaves the chip label-only; it is never an error state and\n * the chip is never removed.\n */\nexport function useMentionResolution(\n  registry: PromptInputMentionRegistry,\n  mentions: PromptInputMention[],\n  actions: EditorActions\n): void {\n  const requestedRef = useRef(new Set<string>());\n  const actionsRef = useRef(actions);\n  actionsRef.current = actions;\n\n  useEffect(() => {\n    if (mentions.length === 0) return;\n\n    const byTrigger = new Map<\n      string,\n      Array<{ type: string; id: string; label: string }>\n    >();\n\n    for (const mention of mentions) {\n      const key = `${mention.trigger}|${mention.type}|${mention.id}`;\n      if (requestedRef.current.has(key)) continue;\n      if (registry.has(mention.trigger, mention.type, mention.id)) continue;\n      const config = registry.get(mention.trigger);\n      if (!config?.resolveMentions) continue;\n      requestedRef.current.add(key);\n      const bucket = byTrigger.get(mention.trigger);\n      const ref = {\n        type: mention.type,\n        id: mention.id,\n        label: mention.label\n      };\n      if (bucket) bucket.push(ref);\n      else byTrigger.set(mention.trigger, [ref]);\n    }\n\n    if (byTrigger.size === 0) return;\n\n    for (const [trigger, refs] of byTrigger) {\n      const resolve = registry.get(trigger)?.resolveMentions;\n      if (!resolve) continue;\n      resolve(refs)\n        .then(items => {\n          if (items.length === 0) return;\n          registry.rememberAll(trigger, items);\n          const labels = new Map<string, string>();\n          for (const item of items) {\n            labels.set(\n              `${trigger}|${item.type ?? 'mention'}|${item.id}`,\n              item.label\n            );\n          }\n          actionsRef.current.refreshMentionLabels(labels);\n        })\n        .catch(() => {\n          // Label-only is the fallback, so there is nothing to recover.\n        });\n    }\n  }, [registry, mentions]);\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;AAyBA;AACA;AAEA;AAEA;;;;;;AAMG;AACG;AAGJ;AAKF;AAEA;;;;AAIG;AACG;;;AAGJ;AAEA;AACE;AACE;;;;;AAKA;;;;AAGA;;;;;;AAMJ;AACE;;AAEF;AACF;AAEA;AACgB;AAId;AAAY;;AAET;;AAEC;;;AAGD;;;AAGH;AACF;AAEA;AACE;AACA;AACF;AAEA;AAKE;;;AAEA;AACE;AACA;AAA6B;;;AAGjC;AA2BgB;;AAUd;AACA;AAEA;AACA;;;;AAMA;AACA;;;AAGA;AACA;;;AAIA;AACA;;AAGE;;;;;;;;;;;AAaA;;AAGA;AACE;;AAEI;;;;AAGF;AACC;AACC;;;;AAGA;;AAGA;;AAGE;AAEA;;;;AAOJ;;AAGJ;AACE;;AAEF;AACF;AAEA;;AAEA;AACA;;;;;;AAOA;AAGO;;;;;AAOP;;;AAIA;;AAG0B;AAC1B;;AAEE;;AACA;AACA;AACF;AAEA;;AAEA;;;;;;;AAOE;;AACA;;AACA;AACF;AAEA;AAEI;AACA;;AACA;AACA;;;;;;AAOG;AAGL;;;;;;AASF;AAEA;AAEI;AACE;;;AAGA;AACA;AACA;AACE;;AAEE;AAMA;AACA;;AACA;;;;;AAKJ;;AAEH;AAIH;AACA;AACA;AACA;AAEA;;AAE0B;AAEtB;AACA;;;;;;AAOA;AAEA;AACE;AACE;;AAEA;AACF;AACE;;AAEA;AACF;AACE;AACA;AACA;AACF;;AAEE;AACA;;;AAGE;AACA;;AAEF;;AAEA;;AAEF;AACE;;AAEN;;;;;;;;AAWA;AACA;;;AAGA;AAEI;AACA;;;AAGR;AAEA;;;;;;;AAOG;;;AAOD;AACA;;AAGE;;AAEA;AAKA;AACE;AACA;;AACA;;;;;AAGA;;AAEA;;;;;AAKA;AAAY;;;;AAId;;;;AAIE;;;;AAGI;;AACA;AACA;AACA;;;AAMA;AACF;;;AAGA;;AAEN;AACF;;;;;;"}