🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@tiptap/suggestion

Package Overview
Dependencies
Maintainers
6
Versions
400
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@tiptap/suggestion - npm Package Compare versions

Comparing version
3.26.1
to
3.27.0
+129
src/helpers.ts
import type { Editor, Range } from '@tiptap/core'
import type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import type { SuggestionMatch } from './findSuggestionMatch.js'
import type { SuggestionOptions, SuggestionPluginState } from './types.js'
/**
* Returns true if the transaction inserted any whitespace or newline character.
* Used to determine when a dismissed suggestion should become active again.
*/
export function hasInsertedWhitespace(transaction: Transaction): boolean {
if (!transaction.docChanged) {
return false
}
return transaction.steps.some(step => {
const slice = (step as any).slice
if (!slice?.content) {
return false
}
// textBetween with '\n' as block separator catches both inline spaces and newlines
const inserted = slice.content.textBetween(0, slice.content.size, '\n')
return /\s/.test(inserted)
})
}
/**
* Gets the DOM rectangle corresponding to the current editor cursor anchor position.
* Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object.
*/
export function getAnchorClientRect(editor: Editor): () => DOMRect | null {
return () => {
const pos = editor.state.selection.$anchor.pos
const coords = editor.view.coordsAtPos(pos)
const { top, right, bottom, left } = coords
try {
return new DOMRect(left, top, right - left, bottom - top)
} catch {
return null
}
}
}
/**
* Creates a clientRect callback for a given decoration node.
* Returns the anchor rect when no decoration node is present.
* Uses the pluginKey's state to resolve the current decoration node on demand.
*/
export function clientRectFor(
editor: Editor,
view: EditorView,
decorationNode: Element | null,
pluginKey: PluginKey,
): () => DOMRect | null {
if (!decorationNode) {
return getAnchorClientRect(editor)
}
return () => {
const state: SuggestionPluginState = pluginKey.getState(editor.state) as any
const decorationId = state?.decorationId
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`)
return currentDecorationNode?.getBoundingClientRect() || null
}
}
/**
* Determines whether a dismissed suggestion should stay dismissed.
* Returns `true` (keep dismissed) or `false` (allow reactivation).
*/
export function shouldKeepDismissed({
match,
dismissedRange,
state,
transaction,
editor,
shouldResetDismissed,
effectiveAllowSpaces,
}: {
match: Exclude<SuggestionMatch, null>
dismissedRange: Range
state: EditorState
transaction: Transaction
editor: Editor
shouldResetDismissed?: SuggestionOptions['shouldResetDismissed']
effectiveAllowSpaces: boolean
}): boolean {
if (
shouldResetDismissed?.({
editor,
state,
range: dismissedRange,
match,
transaction,
allowSpaces: effectiveAllowSpaces,
})
) {
return false
}
if (effectiveAllowSpaces) {
return match.range.from === dismissedRange.from
}
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)
}
/**
* Dispatch an exit of the suggestion plugin by dispatching a metadata-only
* transaction to clear the plugin state. The renderer's onExit hook is NOT
* called here — it fires via the plugin view's stopped transition, which
* builds SuggestionProps consistently with the normal lifecycle.
*
* This prevents a double onExit call (one from dispatchExit, one from the
* view's update) and keeps exitSuggestion consistent with Escape-triggered
* exits.
*/
export function dispatchExit({
view,
pluginKeyRef,
}: {
view: EditorView
pluginKeyRef: PluginKey
}): void {
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
view.dispatch(tr)
}
import type { Editor } from '@tiptap/core'
import type { SuggestionOptions } from '../types.js'
export interface CreateSuggestionAsyncRequestManagerOptions<I = any> {
editor: Editor
items: NonNullable<SuggestionOptions<I>['items']>
}
type AsyncRequestResult<I> =
| { status: 'resolved'; items: I[] }
| { status: 'aborted' }
| { status: 'error' }
export function createSuggestionAsyncRequestManager<I = any>({
editor,
items,
}: CreateSuggestionAsyncRequestManagerOptions<I>) {
let abortController: AbortController | null = null
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let debounceResolve: (() => void) | null = null
const clearDebounceTimer = () => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
debounceResolve?.()
debounceResolve = null
}
const waitForDebounce = (delay: number) => {
return new Promise<void>(resolve => {
debounceResolve = resolve
debounceTimer = setTimeout(() => {
debounceTimer = null
const pendingResolve = debounceResolve
debounceResolve = null
pendingResolve?.()
}, delay)
})
}
const abort = () => {
abortController?.abort()
clearDebounceTimer()
abortController = null
}
const fetch = async (query: string, debounce: number): Promise<AsyncRequestResult<I>> => {
abort()
abortController = new AbortController()
const controller = abortController
if (debounce > 0) {
await waitForDebounce(debounce)
}
if (abortController !== controller || controller.signal.aborted) {
return { status: 'aborted' }
}
try {
const result = await items({
editor,
query,
signal: controller.signal,
})
if (abortController !== controller || controller.signal.aborted) {
return { status: 'aborted' }
}
return { status: 'resolved', items: result }
} catch {
if (abortController !== controller || controller.signal.aborted) {
return { status: 'aborted' }
}
return { status: 'error' }
}
}
return {
abort,
fetch,
}
}
import type { Middleware, VirtualElement } from '@floating-ui/dom'
import {
autoUpdate,
computePosition,
flip as floatingUiFlip,
offset as floatingUiOffset,
} from '@floating-ui/dom'
import type {
SuggestionFloatingUiConfig,
SuggestionFloatingUiOptions,
SuggestionMount,
SuggestionPlacement,
} from '../types.js'
export interface CreateSuggestionFloatingUiConfigOptions {
placement: SuggestionPlacement
offset: { mainAxis?: number; crossAxis?: number }
flip: boolean
floatingUi?: SuggestionFloatingUiOptions
}
export function createSuggestionFloatingUiConfig({
placement,
offset,
flip,
floatingUi,
}: CreateSuggestionFloatingUiConfigOptions): SuggestionFloatingUiConfig {
const middleware: Middleware[] = [
floatingUiOffset({
mainAxis: offset.mainAxis ?? 4,
crossAxis: offset.crossAxis ?? 0,
}),
]
if (flip) {
middleware.push(floatingUiFlip())
}
if (floatingUi?.middleware?.length) {
middleware.push(...floatingUi.middleware)
}
return {
placement,
strategy: floatingUi?.strategy ?? 'absolute',
middleware,
}
}
export interface CreateMountOptions {
/** Returns the current cursor/anchor rect the popup should track. */
getReferenceRect: () => DOMRect | null
/**
* An element inside the editor's layout/scroll context. Floating UI walks up
* from here to discover the scroll ancestors (and the window) to observe, so
* the scroll container does not need to be configured manually.
*/
contextElement: Element
/** Resolved Floating UI config (placement, strategy, middleware). */
config: SuggestionFloatingUiConfig
/**
* CSS selector or element the popup should be mounted into. Defaults to
* `document.body`. Used to portal the popup inside dialogs/modals so it
* renders on top of (and clips within) the right context.
*/
container?: string | HTMLElement
/**
* When `true`, a pointerdown outside both the popup and the editor dismisses
* the suggestion. Wired up and torn down alongside the mounted element.
*/
dismissOnOutsideClick: boolean
/** Dismisses the active suggestion (used by outside-click handling). */
dismiss: () => void
}
/**
* Resolves a container option (selector or element) to a mount target,
* falling back to `document.body` when it can't be resolved.
*/
function resolveContainer(container?: string | HTMLElement): HTMLElement {
if (container instanceof HTMLElement) {
return container
}
if (typeof container === 'string') {
try {
// `container` is consumer-provided; an invalid selector throws a
// DOMException, so fall back to document.body instead of crashing.
const found = document.querySelector<HTMLElement>(container)
if (found) {
return found
}
} catch {
return document.body
}
}
return document.body
}
/**
* Builds the `mount` function handed to the renderer on `SuggestionProps`.
*
* Mounts the popup into the container, then wires Floating UI's `autoUpdate`
* against a virtual reference that re-reads the live cursor rect, so the popup
* stays anchored across scroll, resize, and layout shifts without the consumer
* attaching any listeners. The returned `unmount` tears all of that down.
*/
export function createMount({
getReferenceRect,
contextElement,
config,
container,
dismissOnOutsideClick,
dismiss,
}: CreateMountOptions): SuggestionMount {
return (element, options = {}) => {
const reference: VirtualElement = {
getBoundingClientRect: () => getReferenceRect() ?? new DOMRect(),
contextElement,
}
let positioned = false
// Mount the popup into the container (default `document.body`) unless the
// consumer already placed it in the DOM themselves — in which case we leave
// mounting (and unmounting) to them.
const mountedByUs = !element.isConnected
if (mountedByUs) {
resolveContainer(container).appendChild(element)
}
// Hide the element until the first measurement resolves so it doesn't flash
// at its initial coordinates. Skipped when the consumer owns applying the
// position via `onPosition`.
if (!options.onPosition) {
element.style.visibility = 'hidden'
element.style.width = 'max-content'
}
const update = () => {
computePosition(reference, element, {
placement: config.placement,
strategy: config.strategy,
middleware: config.middleware,
}).then(({ x, y, placement, strategy }) => {
if (options.onPosition) {
options.onPosition({ x, y, placement: placement as SuggestionPlacement, strategy })
return
}
Object.assign(element.style, {
position: strategy,
left: `${x}px`,
top: `${y}px`,
})
if (!positioned) {
positioned = true
element.style.visibility = ''
}
})
}
const cleanupAutoUpdate = autoUpdate(reference, element, update, options.autoUpdate)
// Dismiss when the user interacts outside both the popup and the editor.
// Capture phase so a parent that stops propagation can't swallow it.
let onOutsidePointerDown: ((event: PointerEvent) => void) | undefined
if (dismissOnOutsideClick) {
onOutsidePointerDown = event => {
const target = event.target
if (
!(target instanceof Node) ||
element.contains(target) ||
contextElement.contains(target)
) {
return
}
dismiss()
}
document.addEventListener('pointerdown', onOutsidePointerDown, true)
}
return () => {
cleanupAutoUpdate()
if (onOutsidePointerDown) {
document.removeEventListener('pointerdown', onOutsidePointerDown, true)
}
if (mountedByUs) {
element.remove()
}
}
}
}
import type { EditorState, PluginKey } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { SuggestionKeyDownProps, SuggestionPluginState } from '../types.js'
/**
* Creates the `props` object for the suggestion ProseMirror plugin.
* Contains `handleKeyDown` for keyboard handling and `decorations`
* for rendering the suggestion highlight.
*/
export interface CreateSuggestionPropsOptions {
pluginKey: PluginKey
decorationTag: string
decorationClass: string
decorationContent: string
decorationEmptyClass: string
renderer: { onKeyDown?: (props: SuggestionKeyDownProps) => boolean } | undefined
dispatchExit: (view: EditorView) => void
}
/**
* Creates the `props` object for the suggestion ProseMirror plugin.
* Contains `handleKeyDown` for keyboard handling and `decorations`
* for rendering the suggestion highlight.
*/
export function createSuggestionProps({
pluginKey,
decorationTag,
decorationClass,
decorationContent,
decorationEmptyClass,
renderer,
dispatchExit,
}: CreateSuggestionPropsOptions) {
return {
/**
* Call the keydown hook if suggestion is active.
*/
handleKeyDown(view: EditorView, event: KeyboardEvent) {
const state: SuggestionPluginState = pluginKey.getState(view.state) as any
if (!state.active) {
return false
}
// If Escape is pressed, call onKeyDown and dispatch a metadata-only
// transaction to unset the suggestion state. This provides a safe
// and deterministic way to exit the suggestion without altering the
// document (avoids transaction mapping/mismatch issues).
if (event.key === 'Escape' || event.key === 'Esc') {
// Allow the consumer to react to Escape, but always clear the
// suggestion state afterward so the decoration is removed too.
renderer?.onKeyDown?.({ view, event, range: state.range })
// dispatch metadata-only transaction to unset the plugin state
dispatchExit(view)
return true
}
const handled = renderer?.onKeyDown?.({ view, event, range: state.range }) || false
return handled
},
/**
* Setup decorator on the currently active suggestion.
*/
decorations(state: EditorState) {
const pluginState: SuggestionPluginState = pluginKey.getState(state) as any
const { active, range, decorationId, query } = pluginState
if (!active) {
return null
}
const isEmpty = !query?.length
const classNames = [decorationClass]
if (isEmpty) {
classNames.push(decorationEmptyClass)
}
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, {
nodeName: decorationTag,
class: classNames.join(' '),
'data-decoration-id': decorationId || undefined,
'data-decoration-content': decorationContent,
}),
])
},
}
}
import type { Editor, Range } from '@tiptap/core'
import type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'
import type {
findSuggestionMatch as defaultFindSuggestionMatch,
SuggestionMatch,
} from '../findSuggestionMatch.js'
import type { SuggestionOptions, SuggestionPluginState } from '../types.js'
export interface CreateSuggestionStateOptions {
editor: Editor
char: string
effectiveAllowSpaces: boolean
allowToIncludeChar: boolean
allowedPrefixes: string[] | null
startOfLine: boolean
findSuggestionMatch: typeof defaultFindSuggestionMatch
allow: Exclude<SuggestionOptions['allow'], undefined>
shouldShow?: SuggestionOptions['shouldShow']
shouldKeepDismissed: (props: {
match: Exclude<SuggestionMatch, null>
dismissedRange: Range
state: EditorState
transaction: Transaction
}) => boolean
pluginKey: PluginKey
}
/**
* Creates the `state` object for the suggestion ProseMirror plugin.
* Contains `init()` and `apply()` for managing the plugin's internal state
* across transactions.
*/
export function createSuggestionState({
editor,
char,
effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
findSuggestionMatch,
allow,
shouldShow,
shouldKeepDismissed,
pluginKey,
}: CreateSuggestionStateOptions) {
return {
/**
* Initialize the plugin's internal state.
*/
init(): SuggestionPluginState {
return {
active: false,
range: { from: 0, to: 0 },
query: null,
text: null,
composing: false,
dismissedRange: null,
}
},
/**
* Apply changes to the plugin state from a view transaction.
*/
apply(
transaction: Transaction,
prev: SuggestionPluginState,
_oldState: EditorState,
state: EditorState,
): SuggestionPluginState {
const { isEditable } = editor
const { composing } = editor.view
const { selection } = transaction
const { empty, from } = selection
const next = { ...prev }
// If a transaction carries the exit meta for this plugin, immediately
// deactivate the suggestion. This allows metadata-only transactions
// (dispatched by escape or programmatic exit) to deterministically
// clear decorations without changing the document.
const meta = transaction.getMeta(pluginKey)
if (meta && meta.exit) {
next.active = false
next.decorationId = null
next.range = { from: 0, to: 0 }
next.query = null
next.text = null
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange
return next
}
next.composing = composing
if (transaction.docChanged && next.dismissedRange !== null) {
next.dismissedRange = {
from: transaction.mapping.map(next.dismissedRange.from),
to: transaction.mapping.map(next.dismissedRange.to),
}
}
// We can only be suggesting if the view is editable, and:
// * there is no selection, or
// * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
if (isEditable && (empty || editor.view.composing)) {
// Reset active state if we just left the previous suggestion range
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
next.active = false
}
// Try to match against where our cursor currently is
const match = findSuggestionMatch({
char,
allowSpaces: effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
$position: selection.$from,
})
const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`
// If we found a match, update the current state to show it
if (
match &&
allow({
editor,
state,
range: match.range,
isActive: prev.active,
}) &&
(!shouldShow ||
shouldShow({
editor,
range: match.range,
query: match.query,
text: match.text,
transaction,
}))
) {
if (
next.dismissedRange !== null &&
!shouldKeepDismissed({
match,
dismissedRange: next.dismissedRange,
state,
transaction,
})
) {
next.dismissedRange = null
}
if (next.dismissedRange === null) {
next.active = true
next.decorationId = prev.decorationId || decorationId
next.range = match.range
next.query = match.query
next.text = match.text
} else {
next.active = false
}
} else {
if (!match) {
next.dismissedRange = null
}
next.active = false
}
} else {
next.active = false
}
// Make sure to empty the range if suggestion is inactive
if (!next.active) {
next.decorationId = null
next.range = { from: 0, to: 0 }
next.query = null
next.text = null
}
return next
},
}
}
import type { Editor } from '@tiptap/core'
import type { EditorState, PluginKey } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import type {
PluginState,
SuggestionFloatingUiOptions,
SuggestionOptions,
SuggestionPlacement,
SuggestionProps,
} from '../types.js'
import { createSuggestionAsyncRequestManager } from './async.js'
import { createMount, createSuggestionFloatingUiConfig } from './floating-ui.js'
export interface CreateSuggestionViewOptions {
editor: Editor
pluginKey: PluginKey<PluginState>
items: NonNullable<SuggestionOptions['items']>
renderer: ReturnType<NonNullable<SuggestionOptions['render']>> | undefined
minQueryLength: number
debounce: number
initialItems?: any[]
placement: SuggestionPlacement
offset: { mainAxis?: number; crossAxis?: number }
container?: string | HTMLElement
flip: boolean
floatingUi?: SuggestionFloatingUiOptions
dismissOnOutsideClick: boolean
command: NonNullable<SuggestionOptions['command']>
clientRectFor: (view: EditorView, decorationNode: Element | null) => () => DOMRect | null
dispatchExit: (view: EditorView) => void
}
/**
* Creates the `view` object for the suggestion ProseMirror plugin.
*
* Manages the async lifecycle: tracks state transitions, calls renderer hooks,
* fetches items with debounce and AbortController support.
*
* 1. Tracks plugin state transitions (started, updated, stopped) to determine when to call renderer hooks.
* 2. Calls `onBeforeStart`, `onBeforeUpdate`, `onStart` before fetching to allow the renderer to prepare for first render
* 3. Manages async fetching of suggestion items with support for debouncing and aborting in-flight requests
* 4. Calls `onUpdate` after fetching new items to update the renderer with the latest data
* 5. At the end calls a final `onExit` or `onUpdate` to allow the renderer to clean up or finalize the state
*/
export function createSuggestionView({
editor,
pluginKey,
items,
renderer,
minQueryLength,
debounce,
initialItems,
placement,
offset: offsetOption,
container,
flip,
floatingUi,
dismissOnOutsideClick,
command,
clientRectFor,
dispatchExit,
}: CreateSuggestionViewOptions) {
let props: SuggestionProps | undefined
const asyncRequest = createSuggestionAsyncRequestManager({
editor,
items,
})
const floatingUiConfig = createSuggestionFloatingUiConfig({
placement,
offset: offsetOption,
flip,
floatingUi,
})
function dispatchStateUpdate(
state: 'started' | 'updated' | 'stopped',
dispatchProps: SuggestionProps,
) {
switch (state) {
case 'started':
renderer?.onStart?.(dispatchProps)
break
case 'updated':
renderer?.onUpdate?.(dispatchProps)
break
case 'stopped':
renderer?.onExit?.(dispatchProps)
break
default:
break
}
}
return {
update: async (view: EditorView, prevState: EditorState) => {
const prev = pluginKey.getState(prevState)
const next = pluginKey.getState(view.state)
if (!prev || !next) {
return
}
let currentState: 'started' | 'updated' | 'stopped' | null = null
const queryChanged = prev.query !== next.query
const textChanged = prev.text !== next.text
const rangeChanged = prev.range.from !== next.range.from || prev.range.to !== next.range.to
const effectiveQueryChanged = queryChanged || textChanged || rangeChanged
if (!prev.active && next.active) {
currentState = 'started'
} else if (prev.active && !next.active) {
currentState = 'stopped'
} else if (next.active && effectiveQueryChanged) {
currentState = 'updated'
} else {
return
}
const state = currentState === 'stopped' ? prev : next
const decorationNode = view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`)
const clientRect = clientRectFor(view, decorationNode)
const exceedsMinQueryLength =
minQueryLength === 0 || (state.query ? state.query.length >= minQueryLength : false)
const willFetch =
(currentState === 'started' || currentState === 'updated') && exceedsMinQueryLength
props = {
editor,
range: state.range,
query: state.query || '',
text: state.text || '',
items: initialItems ?? [],
command: commandProps => {
return command({
editor,
range: state.range,
props: commandProps,
})
},
decorationNode,
clientRect,
loading: willFetch,
placement,
offset: { mainAxis: offsetOption.mainAxis ?? 4, crossAxis: offsetOption.crossAxis ?? 0 },
container,
flip,
floatingUi: floatingUiConfig,
mount: createMount({
getReferenceRect: clientRect,
contextElement: view.dom,
config: floatingUiConfig,
container,
dismissOnOutsideClick,
dismiss: () => dispatchExit(editor.view),
}),
}
if (currentState === 'started') {
renderer?.onBeforeStart?.(props)
}
if (currentState === 'updated') {
renderer?.onBeforeUpdate?.(props)
}
// we run the start before we fetch
// to allow for the component to render immediately
if (currentState === 'started') {
dispatchStateUpdate(currentState, props)
}
if (currentState === 'started' || currentState === 'updated') {
if (!willFetch) {
// Abort any in-flight request so stale results don't overwrite
asyncRequest.abort()
props = { ...props, items: initialItems ?? [], loading: false }
} else {
// update the renderer with loading state before we start the async fetch
props = { ...props, items: initialItems ?? [], loading: true }
currentState = 'updated'
dispatchStateUpdate(currentState, props)
const result = await asyncRequest.fetch(state.query || '', debounce)
if (result.status === 'aborted') {
return
}
// Re-check plugin state because the suggestion may have been dismissed
const currentPluginState = pluginKey.getState(view.state)
if (!currentPluginState?.active) {
asyncRequest.abort()
return
}
props =
result.status === 'resolved'
? {
...props,
items: result.items,
loading: false,
}
: {
...props,
loading: false,
}
}
}
if (currentState === 'stopped') {
// stop running updates immediately and call onExit to allow the renderer to clean up
asyncRequest.abort()
dispatchStateUpdate(currentState, props)
props = undefined
return
}
if (currentState === 'updated') {
dispatchStateUpdate(currentState, props)
}
},
destroy: () => {
asyncRequest.abort()
if (!props) {
return
}
renderer?.onExit?.(props)
},
}
}
import type { AutoUpdateOptions, Middleware } from '@floating-ui/dom'
import type { Editor, Range } from '@tiptap/core'
import type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import type {
findSuggestionMatch as defaultFindSuggestionMatch,
SuggestionMatch,
} from './findSuggestionMatch.js'
export type SuggestionPlacement =
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
export type SuggestionFloatingUiOptions = {
strategy?: 'absolute' | 'fixed'
middleware?: Middleware[]
}
export type SuggestionFloatingUiConfig = {
placement: SuggestionPlacement
strategy: 'absolute' | 'fixed'
middleware: Middleware[]
}
/**
* The computed position handed to a custom `onPosition` callback when using
* managed positioning via {@link SuggestionProps.mount}.
*/
export type SuggestionPositionData = {
x: number
y: number
placement: SuggestionPlacement
strategy: 'absolute' | 'fixed'
}
/**
* Options for managed mounting + positioning via {@link SuggestionProps.mount}.
*/
export type SuggestionMountOptions = {
/**
* Override how the computed position is applied to the element.
* When provided, the plugin stops writing `style.left`/`style.top` itself and
* hands you the computed coordinates so you can apply them however you want
* (custom transforms, animation, writing to a framework ref, etc.).
*/
onPosition?: (data: SuggestionPositionData) => void
/**
* Options forwarded to Floating UI's `autoUpdate`. Use this to opt into
* `animationFrame` polling for anchors that move inside transformed or
* animated containers, or to disable specific observers.
* @see https://floating-ui.com/docs/autoUpdate
*/
autoUpdate?: AutoUpdateOptions
}
/**
* Mounts a floating element and takes over its positioning. The plugin appends
* the element into the configured `container` (default `document.body`), keeps
* it anchored to the suggestion's cursor rect, and automatically repositions it
* on scroll, resize, and layout shifts via Floating UI's `autoUpdate` — no
* manual listeners required.
*
* Returns an `unmount` function that tears down the listeners and removes the
* element (when the plugin mounted it). Call it from `onExit`.
*/
export type SuggestionMount = (element: HTMLElement, options?: SuggestionMountOptions) => () => void
export type PluginState = {
active: boolean
range: Range
query: string | null
text: string | null
decorationId?: string
}
export interface SuggestionOptions<I = any, TSelected = any> {
/**
* The plugin key for the suggestion plugin.
* @default 'suggestion'
* @example 'mention'
*/
pluginKey?: PluginKey
/**
* A function that returns a boolean to indicate if the suggestion should be active.
* This is useful to prevent suggestions from opening for remote users in collaborative environments.
* @param props The props object.
* @param props.editor The editor instance.
* @param props.range The range of the suggestion.
* @param props.query The current suggestion query.
* @param props.text The current suggestion text.
* @param props.transaction The current transaction.
* @returns {boolean}
* @example ({ transaction }) => isChangeOrigin(transaction)
*/
shouldShow?: (props: {
editor: Editor
range: Range
query: string
text: string
transaction: Transaction
}) => boolean
/**
* Controls when a dismissed suggestion becomes active again.
* Return `true` to clear the dismissed context for the current transaction.
*/
shouldResetDismissed?: (props: {
editor: Editor
state: EditorState
range: Range
match: Exclude<SuggestionMatch, null>
transaction: Transaction
allowSpaces: boolean
}) => boolean
/**
* The editor instance.
* @default null
*/
editor: Editor
/**
* The character that triggers the suggestion.
* @default '@'
* @example '#'
*/
char?: string
/**
* Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.
* @default false
* @example true
*/
allowSpaces?: boolean
/**
* Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.
* @default false
*/
allowToIncludeChar?: boolean
/**
* Allow prefixes in the suggestion query.
* @default [' ']
* @example [' ', '@']
*/
allowedPrefixes?: string[] | null
/**
* Only match suggestions at the start of the line.
* @default false
* @example true
*/
startOfLine?: boolean
/**
* The tag name of the decoration node.
* @default 'span'
* @example 'div'
*/
decorationTag?: string
/**
* The class name of the decoration node.
* @default 'suggestion'
* @example 'mention'
*/
decorationClass?: string
/**
* Creates a decoration with the provided content.
* @param decorationContent - The content to display in the decoration
* @default "" - Creates an empty decoration if no content provided
*/
decorationContent?: string
/**
* The class name of the decoration node when it is empty.
* @default 'is-empty'
* @example 'is-empty'
*/
decorationEmptyClass?: string
/**
* A function that is called when a suggestion is selected.
* @param props The props object.
* @param props.editor The editor instance.
* @param props.range The range of the suggestion.
* @param props.props The props of the selected suggestion.
* @returns void
* @example ({ editor, range, props }) => { props.command(props.props) }
*/
command?: (props: { editor: Editor; range: Range; props: TSelected }) => void
/**
* Minimum query length before `items()` is called.
* When the query is shorter, empty items are passed to the renderer.
* @default 0 (no filter, same as before)
* @example 2
*/
minQueryLength?: number
/**
* Debounce in milliseconds. When set, `items()` will only be called
* after the user stops typing for this duration.
* @default 0 (no debounce, same as before)
* @example 300
*/
debounce?: number
/**
* Items shown immediately when the suggestion popup opens,
* before the async `items()` call resolves.
* Useful for showing recent or popular items while loading.
* @default undefined (no pre-populated items)
*/
initialItems?: I[]
/**
* Placement of the popup relative to the cursor.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default 'bottom-start'
*/
placement?: SuggestionPlacement
/**
* Offset of the popup in pixels.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default { mainAxis: 4, crossAxis: 0 }
*/
offset?: { mainAxis?: number; crossAxis?: number }
/**
* CSS selector or element that defines the containment context for the popup.
* Consumers can read this from `SuggestionProps` when rendering inside modals or dialogs.
* @default undefined (no containment)
*/
container?: string | HTMLElement
/**
* Whether the popup should automatically flip when there isn't enough space.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default true
*/
flip?: boolean
/**
* Additional Floating UI options and middleware passed through to the renderer.
* The plugin keeps ownership of the anchor and placement, but consumers can
* append custom middleware here.
*/
floatingUi?: SuggestionFloatingUiOptions
/**
* Dismiss the suggestion when the user interacts outside both the popup and
* the editor. Only applies when using managed mounting via
* {@link SuggestionProps.mount} (the plugin needs to know the popup element).
* @default true
*/
dismissOnOutsideClick?: boolean
/**
* A function that returns the suggestion items in form of an array.
* @param props The props object.
* @param props.editor The editor instance.
* @param props.query The current suggestion query.
* @returns An array of suggestion items.
* @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]
*/
items?: (props: { query: string; editor: Editor; signal: AbortSignal }) => I[] | Promise<I[]>
/**
* The render function for the suggestion.
* @returns An object with render functions.
*/
render?: () => {
onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void
onStart?: (props: SuggestionProps<I, TSelected>) => void
onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void
onUpdate?: (props: SuggestionProps<I, TSelected>) => void
onExit?: (props: SuggestionProps<I, TSelected>) => void
onKeyDown?: (props: SuggestionKeyDownProps) => boolean
}
/**
* A function that returns a boolean to indicate if the suggestion should be active.
* @param props The props object.
* @returns {boolean}
*/
allow?: (props: {
editor: Editor
state: EditorState
range: Range
isActive?: boolean
}) => boolean
findSuggestionMatch?: typeof defaultFindSuggestionMatch
}
/**
* The props passed to the suggestion's render functions (onStart, onUpdate, onExit).
*/
export interface SuggestionProps<I = any, TSelected = any> {
/**
* The editor instance.
*/
editor: Editor
/**
* The range of the suggestion.
*/
range: Range
/**
* The current suggestion query.
*/
query: string
/**
* The current suggestion text.
*/
text: string
/**
* The suggestion items array.
*/
items: I[]
/**
* A function that is called when a suggestion is selected.
* @param props The props object.
* @returns void
*/
command: (props: TSelected) => void
/**
* The decoration node HTML element
* @default null
*/
decorationNode: Element | null
/**
* The function that returns the client rect
* @default null
* @example () => new DOMRect(0, 0, 0, 0)
*/
clientRect?: (() => DOMRect | null) | null
/**
* Placement of the popup relative to the cursor.
* @default 'bottom-start'
*/
placement: SuggestionPlacement
/**
* Offset of the popup in pixels.
* @default { mainAxis: 4, crossAxis: 0 }
*/
offset: { mainAxis: number; crossAxis: number }
/**
* CSS selector or element that defines the containment context for the popup.
* @default undefined
*/
container?: string | HTMLElement
/**
* Whether the popup should automatically flip when there isn't enough space.
* @default true
*/
flip: boolean
/**
* Resolved Floating UI config for direct use with `computePosition()`.
* This is the escape hatch: reach for it only when you mount the element
* yourself and want to run the positioning loop manually instead of using
* {@link SuggestionProps.mount}.
*/
floatingUi: SuggestionFloatingUiConfig
/**
* Mounts your floating element and takes over positioning — the recommended,
* default way to render a suggestion popup.
*
* Pass the element you rendered (e.g. from `ReactRenderer`/`VueRenderer`). The
* plugin appends it into the configured `container` (default `document.body`),
* keeps it anchored to the cursor, and repositions it on scroll, resize, and
* layout shifts — no manual listeners required. Returns an `unmount` function
* that tears everything down; call it in `onExit`.
*
* Escape hatch: mount the element yourself and skip this, then run your own
* positioning loop with {@link SuggestionProps.floatingUi} +
* {@link SuggestionProps.clientRect}.
*
* @example
* ```ts
* onStart: props => {
* component = new ReactRenderer(DropdownList, { props, editor: props.editor })
* unmount = props.mount(component.element)
* },
* onExit: () => unmount?.(),
* ```
*/
mount: SuggestionMount
/**
* Whether the items are currently being loaded.
* `true` before the async `items()` call resolves.
* Useful for showing a loading spinner or skeleton.
* @default false
*/
loading: boolean
}
/**
* The props passed to the suggestion's onKeyDown render function
*/
export interface SuggestionKeyDownProps {
view: EditorView
event: KeyboardEvent
range: Range
}
/** @internal Internal state shape for the suggestion plugin. */
export interface SuggestionPluginState {
active: boolean
range: Range
query: null | string
text: null | string
composing: boolean
decorationId?: string | null
dismissedRange: Range | null
}
+619
-270

@@ -33,3 +33,2 @@ "use strict";

var import_state = require("@tiptap/pm/state");
var import_view = require("@tiptap/pm/view");

@@ -87,3 +86,3 @@ // src/findSuggestionMatch.ts

// src/suggestion.ts
// src/helpers.ts
function hasInsertedWhitespace(transaction) {

@@ -102,27 +101,4 @@ if (!transaction.docChanged) {

}
var SuggestionPluginKey = new import_state.PluginKey("suggestion");
function Suggestion({
pluginKey = SuggestionPluginKey,
editor,
char = "@",
allowSpaces = false,
allowToIncludeChar = false,
allowedPrefixes = [" "],
startOfLine = false,
decorationTag = "span",
decorationClass = "suggestion",
decorationContent = "",
decorationEmptyClass = "is-empty",
command = () => null,
items = () => [],
render = () => ({}),
allow = () => true,
findSuggestionMatch: findSuggestionMatch2 = findSuggestionMatch,
shouldShow,
shouldResetDismissed
}) {
let props;
const renderer = render == null ? void 0 : render();
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar;
const getAnchorClientRect = () => {
function getAnchorClientRect(editor) {
return () => {
const pos = editor.state.selection.$anchor.pos;

@@ -137,271 +113,644 @@ const coords = editor.view.coordsAtPos(pos);

};
const clientRectFor = (view, decorationNode) => {
if (!decorationNode) {
return getAnchorClientRect;
}
return () => {
const state = pluginKey.getState(editor.state);
const decorationId = state == null ? void 0 : state.decorationId;
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`);
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
};
}
function clientRectFor(editor, view, decorationNode, pluginKey) {
if (!decorationNode) {
return getAnchorClientRect(editor);
}
return () => {
const state = pluginKey.getState(editor.state);
const decorationId = state == null ? void 0 : state.decorationId;
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`);
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
};
const shouldKeepDismissed = ({
}
function shouldKeepDismissed({
match,
dismissedRange,
state,
transaction,
editor,
shouldResetDismissed,
effectiveAllowSpaces
}) {
if (shouldResetDismissed == null ? void 0 : shouldResetDismissed({
editor,
state,
range: dismissedRange,
match,
dismissedRange,
state,
transaction
}) => {
if (shouldResetDismissed == null ? void 0 : shouldResetDismissed({
editor,
state,
range: dismissedRange,
match,
transaction,
allowSpaces: effectiveAllowSpaces
})) {
return false;
transaction,
allowSpaces: effectiveAllowSpaces
})) {
return false;
}
if (effectiveAllowSpaces) {
return match.range.from === dismissedRange.from;
}
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction);
}
function dispatchExit({
view,
pluginKeyRef
}) {
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true });
view.dispatch(tr);
}
// src/plugin/props.ts
var import_view = require("@tiptap/pm/view");
function createSuggestionProps({
pluginKey,
decorationTag,
decorationClass,
decorationContent,
decorationEmptyClass,
renderer,
dispatchExit: dispatchExit2
}) {
return {
/**
* Call the keydown hook if suggestion is active.
*/
handleKeyDown(view, event) {
var _a, _b;
const state = pluginKey.getState(view.state);
if (!state.active) {
return false;
}
if (event.key === "Escape" || event.key === "Esc") {
(_a = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _a.call(renderer, { view, event, range: state.range });
dispatchExit2(view);
return true;
}
const handled = ((_b = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _b.call(renderer, { view, event, range: state.range })) || false;
return handled;
},
/**
* Setup decorator on the currently active suggestion.
*/
decorations(state) {
const pluginState = pluginKey.getState(state);
const { active, range, decorationId, query } = pluginState;
if (!active) {
return null;
}
const isEmpty = !(query == null ? void 0 : query.length);
const classNames = [decorationClass];
if (isEmpty) {
classNames.push(decorationEmptyClass);
}
return import_view.DecorationSet.create(state.doc, [
import_view.Decoration.inline(range.from, range.to, {
nodeName: decorationTag,
class: classNames.join(" "),
"data-decoration-id": decorationId || void 0,
"data-decoration-content": decorationContent
})
]);
}
if (effectiveAllowSpaces) {
return match.range.from === dismissedRange.from;
}
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction);
};
function dispatchExit(view, pluginKeyRef) {
var _a;
try {
const state = pluginKey.getState(view.state);
const decorationNode = (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null;
const exitProps = {
// @ts-ignore editor is available in closure
editor,
range: (state == null ? void 0 : state.range) || { from: 0, to: 0 },
query: (state == null ? void 0 : state.query) || null,
text: (state == null ? void 0 : state.text) || null,
items: [],
command: (commandProps) => {
return command({
editor,
range: (state == null ? void 0 : state.range) || { from: 0, to: 0 },
props: commandProps
});
},
decorationNode,
clientRect: clientRectFor(view, decorationNode)
};
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, exitProps);
} catch {
}
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true });
view.dispatch(tr);
}
const plugin = new import_state.Plugin({
key: pluginKey,
view() {
}
// src/plugin/state.ts
function createSuggestionState({
editor,
char,
effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
findSuggestionMatch: findSuggestionMatch2,
allow,
shouldShow,
shouldKeepDismissed: shouldKeepDismissed2,
pluginKey
}) {
return {
/**
* Initialize the plugin's internal state.
*/
init() {
return {
update: async (view, prevState) => {
var _a, _b, _c, _d, _e, _f, _g;
const prev = (_a = this.key) == null ? void 0 : _a.getState(prevState);
const next = (_b = this.key) == null ? void 0 : _b.getState(view.state);
const moved = prev.active && next.active && prev.range.from !== next.range.from;
const started = !prev.active && next.active;
const stopped = prev.active && !next.active;
const changed = !started && !stopped && prev.query !== next.query;
const handleStart = started || moved && changed;
const handleChange = changed || moved;
const handleExit = stopped || moved && changed;
if (!handleStart && !handleChange && !handleExit) {
return;
}
const state = handleExit && !handleStart ? prev : next;
const decorationNode = view.dom.querySelector(
`[data-decoration-id="${state.decorationId}"]`
);
props = {
editor,
range: state.range,
query: state.query,
text: state.text,
items: [],
command: (commandProps) => {
return command({
editor,
range: state.range,
props: commandProps
});
},
decorationNode,
clientRect: clientRectFor(view, decorationNode)
};
if (handleStart) {
(_c = renderer == null ? void 0 : renderer.onBeforeStart) == null ? void 0 : _c.call(renderer, props);
}
if (handleChange) {
(_d = renderer == null ? void 0 : renderer.onBeforeUpdate) == null ? void 0 : _d.call(renderer, props);
}
if (handleChange || handleStart) {
props.items = await items({
editor,
query: state.query
});
}
if (handleExit) {
(_e = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _e.call(renderer, props);
}
if (handleChange) {
(_f = renderer == null ? void 0 : renderer.onUpdate) == null ? void 0 : _f.call(renderer, props);
}
if (handleStart) {
(_g = renderer == null ? void 0 : renderer.onStart) == null ? void 0 : _g.call(renderer, props);
}
},
destroy: () => {
var _a;
if (!props) {
return;
}
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, props);
}
active: false,
range: { from: 0, to: 0 },
query: null,
text: null,
composing: false,
dismissedRange: null
};
},
state: {
// Initialize the plugin's internal state.
init() {
const state = {
active: false,
range: {
from: 0,
to: 0
},
query: null,
text: null,
composing: false,
dismissedRange: null
/**
* Apply changes to the plugin state from a view transaction.
*/
apply(transaction, prev, _oldState, state) {
const { isEditable } = editor;
const { composing } = editor.view;
const { selection } = transaction;
const { empty, from } = selection;
const next = { ...prev };
const meta = transaction.getMeta(pluginKey);
if (meta && meta.exit) {
next.active = false;
next.decorationId = null;
next.range = { from: 0, to: 0 };
next.query = null;
next.text = null;
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange;
return next;
}
next.composing = composing;
if (transaction.docChanged && next.dismissedRange !== null) {
next.dismissedRange = {
from: transaction.mapping.map(next.dismissedRange.from),
to: transaction.mapping.map(next.dismissedRange.to)
};
return state;
},
// Apply changes to the plugin state from a view transaction.
apply(transaction, prev, _oldState, state) {
const { isEditable } = editor;
const { composing } = editor.view;
const { selection } = transaction;
const { empty, from } = selection;
const next = { ...prev };
const meta = transaction.getMeta(pluginKey);
if (meta && meta.exit) {
}
if (isEditable && (empty || editor.view.composing)) {
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
next.active = false;
next.decorationId = null;
next.range = { from: 0, to: 0 };
next.query = null;
next.text = null;
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange;
return next;
}
next.composing = composing;
if (transaction.docChanged && next.dismissedRange !== null) {
next.dismissedRange = {
from: transaction.mapping.map(next.dismissedRange.from),
to: transaction.mapping.map(next.dismissedRange.to)
};
}
if (isEditable && (empty || editor.view.composing)) {
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
next.active = false;
}
const match = findSuggestionMatch2({
char,
allowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
$position: selection.$from
});
const decorationId = `id_${Math.floor(Math.random() * 4294967295)}`;
if (match && allow({
editor,
const match = findSuggestionMatch2({
char,
allowSpaces: effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
$position: selection.$from
});
const decorationId = `id_${Math.floor(Math.random() * 4294967295)}`;
if (match && allow({
editor,
state,
range: match.range,
isActive: prev.active
}) && (!shouldShow || shouldShow({
editor,
range: match.range,
query: match.query,
text: match.text,
transaction
}))) {
if (next.dismissedRange !== null && !shouldKeepDismissed2({
match,
dismissedRange: next.dismissedRange,
state,
range: match.range,
isActive: prev.active
}) && (!shouldShow || shouldShow({
editor,
range: match.range,
query: match.query,
text: match.text,
transaction
}))) {
if (next.dismissedRange !== null && !shouldKeepDismissed({
match,
dismissedRange: next.dismissedRange,
state,
transaction
})) {
next.dismissedRange = null;
}
if (next.dismissedRange === null) {
next.active = true;
next.decorationId = prev.decorationId ? prev.decorationId : decorationId;
next.range = match.range;
next.query = match.query;
next.text = match.text;
} else {
next.active = false;
}
})) {
next.dismissedRange = null;
}
if (next.dismissedRange === null) {
next.active = true;
next.decorationId = prev.decorationId || decorationId;
next.range = match.range;
next.query = match.query;
next.text = match.text;
} else {
if (!match) {
next.dismissedRange = null;
}
next.active = false;
}
} else {
if (!match) {
next.dismissedRange = null;
}
next.active = false;
}
if (!next.active) {
next.decorationId = null;
next.range = { from: 0, to: 0 };
next.query = null;
next.text = null;
}
return next;
} else {
next.active = false;
}
},
props: {
// Call the keydown hook if suggestion is active.
handleKeyDown(view, event) {
var _a, _b;
const { active, range } = plugin.getState(view.state);
if (!active) {
return false;
if (!next.active) {
next.decorationId = null;
next.range = { from: 0, to: 0 };
next.query = null;
next.text = null;
}
return next;
}
};
}
// src/plugin/async.ts
function createSuggestionAsyncRequestManager({
editor,
items
}) {
let abortController = null;
let debounceTimer = null;
let debounceResolve = null;
const clearDebounceTimer = () => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
debounceResolve == null ? void 0 : debounceResolve();
debounceResolve = null;
};
const waitForDebounce = (delay) => {
return new Promise((resolve) => {
debounceResolve = resolve;
debounceTimer = setTimeout(() => {
debounceTimer = null;
const pendingResolve = debounceResolve;
debounceResolve = null;
pendingResolve == null ? void 0 : pendingResolve();
}, delay);
});
};
const abort = () => {
abortController == null ? void 0 : abortController.abort();
clearDebounceTimer();
abortController = null;
};
const fetch = async (query, debounce) => {
abort();
abortController = new AbortController();
const controller = abortController;
if (debounce > 0) {
await waitForDebounce(debounce);
}
if (abortController !== controller || controller.signal.aborted) {
return { status: "aborted" };
}
try {
const result = await items({
editor,
query,
signal: controller.signal
});
if (abortController !== controller || controller.signal.aborted) {
return { status: "aborted" };
}
return { status: "resolved", items: result };
} catch {
if (abortController !== controller || controller.signal.aborted) {
return { status: "aborted" };
}
return { status: "error" };
}
};
return {
abort,
fetch
};
}
// src/plugin/floating-ui.ts
var import_dom = require("@floating-ui/dom");
function createSuggestionFloatingUiConfig({
placement,
offset,
flip,
floatingUi
}) {
var _a, _b, _c, _d;
const middleware = [
(0, import_dom.offset)({
mainAxis: (_a = offset.mainAxis) != null ? _a : 4,
crossAxis: (_b = offset.crossAxis) != null ? _b : 0
})
];
if (flip) {
middleware.push((0, import_dom.flip)());
}
if ((_c = floatingUi == null ? void 0 : floatingUi.middleware) == null ? void 0 : _c.length) {
middleware.push(...floatingUi.middleware);
}
return {
placement,
strategy: (_d = floatingUi == null ? void 0 : floatingUi.strategy) != null ? _d : "absolute",
middleware
};
}
function resolveContainer(container) {
if (container instanceof HTMLElement) {
return container;
}
if (typeof container === "string") {
try {
const found = document.querySelector(container);
if (found) {
return found;
}
} catch {
return document.body;
}
}
return document.body;
}
function createMount({
getReferenceRect,
contextElement,
config,
container,
dismissOnOutsideClick,
dismiss
}) {
return (element, options = {}) => {
const reference = {
getBoundingClientRect: () => {
var _a;
return (_a = getReferenceRect()) != null ? _a : new DOMRect();
},
contextElement
};
let positioned = false;
const mountedByUs = !element.isConnected;
if (mountedByUs) {
resolveContainer(container).appendChild(element);
}
if (!options.onPosition) {
element.style.visibility = "hidden";
element.style.width = "max-content";
}
const update = () => {
(0, import_dom.computePosition)(reference, element, {
placement: config.placement,
strategy: config.strategy,
middleware: config.middleware
}).then(({ x, y, placement, strategy }) => {
if (options.onPosition) {
options.onPosition({ x, y, placement, strategy });
return;
}
if (event.key === "Escape" || event.key === "Esc") {
const state = plugin.getState(view.state);
(_a = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _a.call(renderer, { view, event, range: state.range });
dispatchExit(view, pluginKey);
return true;
Object.assign(element.style, {
position: strategy,
left: `${x}px`,
top: `${y}px`
});
if (!positioned) {
positioned = true;
element.style.visibility = "";
}
const handled = ((_b = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _b.call(renderer, { view, event, range })) || false;
return handled;
},
// Setup decorator on the currently active suggestion.
decorations(state) {
const { active, range, decorationId, query } = plugin.getState(state);
if (!active) {
return null;
});
};
const cleanupAutoUpdate = (0, import_dom.autoUpdate)(reference, element, update, options.autoUpdate);
let onOutsidePointerDown;
if (dismissOnOutsideClick) {
onOutsidePointerDown = (event) => {
const target = event.target;
if (!(target instanceof Node) || element.contains(target) || contextElement.contains(target)) {
return;
}
const isEmpty = !(query == null ? void 0 : query.length);
const classNames = [decorationClass];
if (isEmpty) {
classNames.push(decorationEmptyClass);
dismiss();
};
document.addEventListener("pointerdown", onOutsidePointerDown, true);
}
return () => {
cleanupAutoUpdate();
if (onOutsidePointerDown) {
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
}
if (mountedByUs) {
element.remove();
}
};
};
}
// src/plugin/view.ts
function createSuggestionView({
editor,
pluginKey,
items,
renderer,
minQueryLength,
debounce,
initialItems,
placement,
offset: offsetOption,
container,
flip,
floatingUi,
dismissOnOutsideClick,
command,
clientRectFor: clientRectFor2,
dispatchExit: dispatchExit2
}) {
let props;
const asyncRequest = createSuggestionAsyncRequestManager({
editor,
items
});
const floatingUiConfig = createSuggestionFloatingUiConfig({
placement,
offset: offsetOption,
flip,
floatingUi
});
function dispatchStateUpdate(state, dispatchProps) {
var _a, _b, _c;
switch (state) {
case "started":
(_a = renderer == null ? void 0 : renderer.onStart) == null ? void 0 : _a.call(renderer, dispatchProps);
break;
case "updated":
(_b = renderer == null ? void 0 : renderer.onUpdate) == null ? void 0 : _b.call(renderer, dispatchProps);
break;
case "stopped":
(_c = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _c.call(renderer, dispatchProps);
break;
default:
break;
}
}
return {
update: async (view, prevState) => {
var _a, _b, _c, _d;
const prev = pluginKey.getState(prevState);
const next = pluginKey.getState(view.state);
if (!prev || !next) {
return;
}
let currentState = null;
const queryChanged = prev.query !== next.query;
const textChanged = prev.text !== next.text;
const rangeChanged = prev.range.from !== next.range.from || prev.range.to !== next.range.to;
const effectiveQueryChanged = queryChanged || textChanged || rangeChanged;
if (!prev.active && next.active) {
currentState = "started";
} else if (prev.active && !next.active) {
currentState = "stopped";
} else if (next.active && effectiveQueryChanged) {
currentState = "updated";
} else {
return;
}
const state = currentState === "stopped" ? prev : next;
const decorationNode = view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`);
const clientRect = clientRectFor2(view, decorationNode);
const exceedsMinQueryLength = minQueryLength === 0 || (state.query ? state.query.length >= minQueryLength : false);
const willFetch = (currentState === "started" || currentState === "updated") && exceedsMinQueryLength;
props = {
editor,
range: state.range,
query: state.query || "",
text: state.text || "",
items: initialItems != null ? initialItems : [],
command: (commandProps) => {
return command({
editor,
range: state.range,
props: commandProps
});
},
decorationNode,
clientRect,
loading: willFetch,
placement,
offset: { mainAxis: (_a = offsetOption.mainAxis) != null ? _a : 4, crossAxis: (_b = offsetOption.crossAxis) != null ? _b : 0 },
container,
flip,
floatingUi: floatingUiConfig,
mount: createMount({
getReferenceRect: clientRect,
contextElement: view.dom,
config: floatingUiConfig,
container,
dismissOnOutsideClick,
dismiss: () => dispatchExit2(editor.view)
})
};
if (currentState === "started") {
(_c = renderer == null ? void 0 : renderer.onBeforeStart) == null ? void 0 : _c.call(renderer, props);
}
if (currentState === "updated") {
(_d = renderer == null ? void 0 : renderer.onBeforeUpdate) == null ? void 0 : _d.call(renderer, props);
}
if (currentState === "started") {
dispatchStateUpdate(currentState, props);
}
if (currentState === "started" || currentState === "updated") {
if (!willFetch) {
asyncRequest.abort();
props = { ...props, items: initialItems != null ? initialItems : [], loading: false };
} else {
props = { ...props, items: initialItems != null ? initialItems : [], loading: true };
currentState = "updated";
dispatchStateUpdate(currentState, props);
const result = await asyncRequest.fetch(state.query || "", debounce);
if (result.status === "aborted") {
return;
}
const currentPluginState = pluginKey.getState(view.state);
if (!(currentPluginState == null ? void 0 : currentPluginState.active)) {
asyncRequest.abort();
return;
}
props = result.status === "resolved" ? {
...props,
items: result.items,
loading: false
} : {
...props,
loading: false
};
}
return import_view.DecorationSet.create(state.doc, [
import_view.Decoration.inline(range.from, range.to, {
nodeName: decorationTag,
class: classNames.join(" "),
"data-decoration-id": decorationId,
"data-decoration-content": decorationContent
})
]);
}
if (currentState === "stopped") {
asyncRequest.abort();
dispatchStateUpdate(currentState, props);
props = void 0;
return;
}
if (currentState === "updated") {
dispatchStateUpdate(currentState, props);
}
},
destroy: () => {
var _a;
asyncRequest.abort();
if (!props) {
return;
}
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, props);
}
};
}
// src/suggestion.ts
var SuggestionPluginKey = new import_state.PluginKey("suggestion");
function Suggestion({
pluginKey = SuggestionPluginKey,
editor,
char = "@",
allowSpaces = false,
allowToIncludeChar = false,
allowedPrefixes = [" "],
startOfLine = false,
decorationTag = "span",
decorationClass = "suggestion",
decorationContent = "",
decorationEmptyClass = "is-empty",
command = () => null,
items = () => [],
minQueryLength = 0,
debounce = 0,
initialItems,
placement = "bottom-start",
offset: offsetOption = {},
container,
flip = true,
floatingUi,
dismissOnOutsideClick = true,
render = () => ({}),
allow = () => true,
findSuggestionMatch: findSuggestionMatch2 = findSuggestionMatch,
shouldShow,
shouldResetDismissed
}) {
const renderer = render == null ? void 0 : render();
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar;
const clientRectFor2 = (view, decorationNode) => clientRectFor(editor, view, decorationNode, pluginKey);
function shouldKeepDismissed2(props) {
return shouldKeepDismissed({
...props,
editor,
shouldResetDismissed,
effectiveAllowSpaces
});
}
const dispatchExit2 = (view) => dispatchExit({
view,
pluginKeyRef: pluginKey
});
return plugin;
return new import_state.Plugin({
key: pluginKey,
view: () => createSuggestionView({
editor,
pluginKey,
items,
renderer,
minQueryLength,
debounce,
initialItems,
placement,
offset: offsetOption,
container,
flip,
floatingUi,
dismissOnOutsideClick,
command,
clientRectFor: clientRectFor2,
dispatchExit: dispatchExit2
}),
state: createSuggestionState({
editor,
char,
effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
findSuggestionMatch: findSuggestionMatch2,
allow,
shouldShow,
shouldKeepDismissed: shouldKeepDismissed2,
pluginKey
}),
props: createSuggestionProps({
pluginKey,
decorationTag,
decorationClass,
decorationContent,
decorationEmptyClass,
renderer,
dispatchExit: dispatchExit2
})
});
}

@@ -408,0 +757,0 @@ function exitSuggestion(view, pluginKeyRef = SuggestionPluginKey) {

@@ -1,1 +0,1 @@

{"version":3,"sources":["../src/index.ts","../src/suggestion.ts","../src/findSuggestionMatch.ts"],"sourcesContent":["import { exitSuggestion, Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport { exitSuggestion }\n\nexport default Suggestion\n","import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nimport type { SuggestionMatch } from './findSuggestionMatch.js'\nimport { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'\n\n/**\n * Returns true if the transaction inserted any whitespace or newline character.\n * Used to determine when a dismissed suggestion should become active again.\n */\nfunction hasInsertedWhitespace(transaction: Transaction): boolean {\n if (!transaction.docChanged) {\n return false\n }\n return transaction.steps.some(step => {\n const slice = (step as any).slice\n if (!slice?.content) {\n return false\n }\n // textBetween with '\\n' as block separator catches both inline spaces and newlines\n const inserted = slice.content.textBetween(0, slice.content.size, '\\n')\n return /\\s/.test(inserted)\n })\n}\n\nexport interface SuggestionOptions<I = any, TSelected = any> {\n /**\n * The plugin key for the suggestion plugin.\n * @default 'suggestion'\n * @example 'mention'\n */\n pluginKey?: PluginKey\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * This is useful to prevent suggestions from opening for remote users in collaborative environments.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.query The current suggestion query.\n * @param props.text The current suggestion text.\n * @param props.transaction The current transaction.\n * @returns {boolean}\n * @example ({ transaction }) => isChangeOrigin(transaction)\n */\n shouldShow?: (props: {\n editor: Editor\n range: Range\n query: string\n text: string\n transaction: Transaction\n }) => boolean\n\n /**\n * Controls when a dismissed suggestion becomes active again.\n * Return `true` to clear the dismissed context for the current transaction.\n */\n shouldResetDismissed?: (props: {\n editor: Editor\n state: EditorState\n range: Range\n match: Exclude<SuggestionMatch, null>\n transaction: Transaction\n allowSpaces: boolean\n }) => boolean\n\n /**\n * The editor instance.\n * @default null\n */\n editor: Editor\n\n /**\n * The character that triggers the suggestion.\n * @default '@'\n * @example '#'\n */\n char?: string\n\n /**\n * Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.\n * @default false\n * @example true\n */\n allowSpaces?: boolean\n\n /**\n * Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.\n * @default false\n */\n allowToIncludeChar?: boolean\n\n /**\n * Allow prefixes in the suggestion query.\n * @default [' ']\n * @example [' ', '@']\n */\n allowedPrefixes?: string[] | null\n\n /**\n * Only match suggestions at the start of the line.\n * @default false\n * @example true\n */\n startOfLine?: boolean\n\n /**\n * The tag name of the decoration node.\n * @default 'span'\n * @example 'div'\n */\n decorationTag?: string\n\n /**\n * The class name of the decoration node.\n * @default 'suggestion'\n * @example 'mention'\n */\n decorationClass?: string\n\n /**\n * Creates a decoration with the provided content.\n * @param decorationContent - The content to display in the decoration\n * @default \"\" - Creates an empty decoration if no content provided\n */\n decorationContent?: string\n\n /**\n * The class name of the decoration node when it is empty.\n * @default 'is-empty'\n * @example 'is-empty'\n */\n decorationEmptyClass?: string\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.props The props of the selected suggestion.\n * @returns void\n * @example ({ editor, range, props }) => { props.command(props.props) }\n */\n command?: (props: { editor: Editor; range: Range; props: TSelected }) => void\n\n /**\n * A function that returns the suggestion items in form of an array.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.query The current suggestion query.\n * @returns An array of suggestion items.\n * @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]\n */\n items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>\n\n /**\n * The render function for the suggestion.\n * @returns An object with render functions.\n */\n render?: () => {\n onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void\n onStart?: (props: SuggestionProps<I, TSelected>) => void\n onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onExit?: (props: SuggestionProps<I, TSelected>) => void\n onKeyDown?: (props: SuggestionKeyDownProps) => boolean\n }\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * @param props The props object.\n * @returns {boolean}\n */\n allow?: (props: {\n editor: Editor\n state: EditorState\n range: Range\n isActive?: boolean\n }) => boolean\n findSuggestionMatch?: typeof defaultFindSuggestionMatch\n}\n\nexport interface SuggestionProps<I = any, TSelected = any> {\n /**\n * The editor instance.\n */\n editor: Editor\n\n /**\n * The range of the suggestion.\n */\n range: Range\n\n /**\n * The current suggestion query.\n */\n query: string\n\n /**\n * The current suggestion text.\n */\n text: string\n\n /**\n * The suggestion items array.\n */\n items: I[]\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @returns void\n */\n command: (props: TSelected) => void\n\n /**\n * The decoration node HTML element\n * @default null\n */\n decorationNode: Element | null\n\n /**\n * The function that returns the client rect\n * @default null\n * @example () => new DOMRect(0, 0, 0, 0)\n */\n clientRect?: (() => DOMRect | null) | null\n}\n\nexport interface SuggestionKeyDownProps {\n view: EditorView\n event: KeyboardEvent\n range: Range\n}\n\nexport const SuggestionPluginKey = new PluginKey('suggestion')\n\n/**\n * This utility allows you to create suggestions.\n * @see https://tiptap.dev/api/utilities/suggestion\n */\nexport function Suggestion<I = any, TSelected = any>({\n pluginKey = SuggestionPluginKey,\n editor,\n char = '@',\n allowSpaces = false,\n allowToIncludeChar = false,\n allowedPrefixes = [' '],\n startOfLine = false,\n decorationTag = 'span',\n decorationClass = 'suggestion',\n decorationContent = '',\n decorationEmptyClass = 'is-empty',\n command = () => null,\n items = () => [],\n render = () => ({}),\n allow = () => true,\n findSuggestionMatch = defaultFindSuggestionMatch,\n shouldShow,\n shouldResetDismissed,\n}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\n const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar\n\n // Gets the DOM rectangle corresponding to the current editor cursor anchor position\n // Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object\n const getAnchorClientRect = () => {\n const pos = editor.state.selection.$anchor.pos\n const coords = editor.view.coordsAtPos(pos)\n const { top, right, bottom, left } = coords\n\n try {\n return new DOMRect(left, top, right - left, bottom - top)\n } catch {\n return null\n }\n }\n\n // Helper to create a clientRect callback for a given decoration node.\n // Returns null when no decoration node is present. Uses the pluginKey's\n // state to resolve the current decoration node on demand, avoiding a\n // duplicated implementation in multiple places.\n const clientRectFor = (view: EditorView, decorationNode: Element | null) => {\n if (!decorationNode) {\n return getAnchorClientRect\n }\n\n return () => {\n const state = pluginKey.getState(editor.state)\n const decorationId = state?.decorationId\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n }\n\n const shouldKeepDismissed = ({\n match,\n dismissedRange,\n state,\n transaction,\n }: {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n }) => {\n if (\n shouldResetDismissed?.({\n editor,\n state,\n range: dismissedRange,\n match,\n transaction,\n allowSpaces: effectiveAllowSpaces,\n })\n ) {\n return false\n }\n\n if (effectiveAllowSpaces) {\n return match.range.from === dismissedRange.from\n }\n\n return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)\n }\n\n // small helper used internally by the view to dispatch an exit\n function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {\n try {\n const state = pluginKey.getState(view.state)\n const decorationNode = state?.decorationId\n ? view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n : null\n\n const exitProps: SuggestionProps = {\n // @ts-ignore editor is available in closure\n editor,\n range: state?.range || { from: 0, to: 0 },\n query: state?.query || null,\n text: state?.text || null,\n items: [],\n command: commandProps => {\n return command({\n editor,\n range: state?.range || { from: 0, to: 0 },\n props: commandProps as any,\n })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n renderer?.onExit?.(exitProps)\n } catch {\n // ignore errors from consumer renderers\n }\n\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n // Dispatch a metadata-only transaction to signal the plugin to exit\n view.dispatch(tr)\n }\n\n const plugin: Plugin<any> = new Plugin({\n key: pluginKey,\n\n view() {\n return {\n update: async (view, prevState) => {\n const prev = this.key?.getState(prevState)\n const next = this.key?.getState(view.state)\n\n // See how the state changed\n const moved = prev.active && next.active && prev.range.from !== next.range.from\n const started = !prev.active && next.active\n const stopped = prev.active && !next.active\n const changed = !started && !stopped && prev.query !== next.query\n\n const handleStart = started || (moved && changed)\n const handleChange = changed || moved\n const handleExit = stopped || (moved && changed)\n\n // Cancel when suggestion isn't active\n if (!handleStart && !handleChange && !handleExit) {\n return\n }\n\n const state = handleExit && !handleStart ? prev : next\n const decorationNode = view.dom.querySelector(\n `[data-decoration-id=\"${state.decorationId}\"]`,\n )\n\n props = {\n editor,\n range: state.range,\n query: state.query,\n text: state.text,\n items: [],\n command: commandProps => {\n return command({\n editor,\n range: state.range,\n props: commandProps,\n })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n if (handleStart) {\n renderer?.onBeforeStart?.(props)\n }\n\n if (handleChange) {\n renderer?.onBeforeUpdate?.(props)\n }\n\n if (handleChange || handleStart) {\n props.items = await items({\n editor,\n query: state.query,\n })\n }\n\n if (handleExit) {\n renderer?.onExit?.(props)\n }\n\n if (handleChange) {\n renderer?.onUpdate?.(props)\n }\n\n if (handleStart) {\n renderer?.onStart?.(props)\n }\n },\n\n destroy: () => {\n if (!props) {\n return\n }\n\n renderer?.onExit?.(props)\n },\n }\n },\n\n state: {\n // Initialize the plugin's internal state.\n init() {\n const state: {\n active: boolean\n range: Range\n query: null | string\n text: null | string\n composing: boolean\n decorationId?: string | null\n dismissedRange: Range | null\n } = {\n active: false,\n range: {\n from: 0,\n to: 0,\n },\n query: null,\n text: null,\n composing: false,\n dismissedRange: null,\n }\n\n return state\n },\n\n // Apply changes to the plugin state from a view transaction.\n apply(transaction, prev, _oldState, state) {\n const { isEditable } = editor\n const { composing } = editor.view\n const { selection } = transaction\n const { empty, from } = selection\n const next = { ...prev }\n\n // If a transaction carries the exit meta for this plugin, immediately\n // deactivate the suggestion. This allows metadata-only transactions\n // (dispatched by escape or programmatic exit) to deterministically\n // clear decorations without changing the document.\n const meta = transaction.getMeta(pluginKey)\n if (meta && meta.exit) {\n next.active = false\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange\n\n return next\n }\n\n next.composing = composing\n\n if (transaction.docChanged && next.dismissedRange !== null) {\n next.dismissedRange = {\n from: transaction.mapping.map(next.dismissedRange.from),\n to: transaction.mapping.map(next.dismissedRange.to),\n }\n }\n\n // We can only be suggesting if the view is editable, and:\n // * there is no selection, or\n // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)\n if (isEditable && (empty || editor.view.composing)) {\n // Reset active state if we just left the previous suggestion range\n if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {\n next.active = false\n }\n\n // Try to match against where our cursor currently is\n const match = findSuggestionMatch({\n char,\n allowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position: selection.$from,\n })\n const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`\n\n // If we found a match, update the current state to show it\n if (\n match &&\n allow({\n editor,\n state,\n range: match.range,\n isActive: prev.active,\n }) &&\n (!shouldShow ||\n shouldShow({\n editor,\n range: match.range,\n query: match.query,\n text: match.text,\n transaction,\n }))\n ) {\n if (\n next.dismissedRange !== null &&\n !shouldKeepDismissed({\n match,\n dismissedRange: next.dismissedRange,\n state,\n transaction,\n })\n ) {\n next.dismissedRange = null\n }\n\n if (next.dismissedRange === null) {\n next.active = true\n next.decorationId = prev.decorationId ? prev.decorationId : decorationId\n next.range = match.range\n next.query = match.query\n next.text = match.text\n } else {\n next.active = false\n }\n } else {\n if (!match) {\n next.dismissedRange = null\n }\n next.active = false\n }\n } else {\n next.active = false\n }\n\n // Make sure to empty the range if suggestion is inactive\n if (!next.active) {\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n }\n\n return next\n },\n },\n\n props: {\n // Call the keydown hook if suggestion is active.\n handleKeyDown(view, event) {\n const { active, range } = plugin.getState(view.state)\n\n if (!active) {\n return false\n }\n\n // If Escape is pressed, call onExit and dispatch a metadata-only\n // transaction to unset the suggestion state. This provides a safe\n // and deterministic way to exit the suggestion without altering the\n // document (avoids transaction mapping/mismatch issues).\n if (event.key === 'Escape' || event.key === 'Esc') {\n const state = plugin.getState(view.state)\n\n // Allow the consumer to react to Escape, but always clear the\n // suggestion state afterward so the decoration is removed too.\n renderer?.onKeyDown?.({ view, event, range: state.range })\n\n // dispatch metadata-only transaction to unset the plugin state\n dispatchExit(view, pluginKey)\n\n return true\n }\n\n const handled = renderer?.onKeyDown?.({ view, event, range }) || false\n return handled\n },\n\n // Setup decorator on the currently active suggestion.\n decorations(state) {\n const { active, range, decorationId, query } = plugin.getState(state)\n\n if (!active) {\n return null\n }\n\n const isEmpty = !query?.length\n const classNames = [decorationClass]\n\n if (isEmpty) {\n classNames.push(decorationEmptyClass)\n }\n\n return DecorationSet.create(state.doc, [\n Decoration.inline(range.from, range.to, {\n nodeName: decorationTag,\n class: classNames.join(' '),\n 'data-decoration-id': decorationId,\n 'data-decoration-content': decorationContent,\n }),\n ])\n },\n },\n })\n\n return plugin\n}\n\n/**\n * Programmatically exit a suggestion plugin by dispatching a metadata-only\n * transaction. This is the safe, recommended API to remove suggestion\n * decorations without touching the document or causing mapping errors.\n */\nexport function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { Range } from '@tiptap/core'\nimport { escapeForRegEx } from '@tiptap/core'\nimport type { ResolvedPos } from '@tiptap/pm/model'\n\nexport interface Trigger {\n char: string\n allowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n $position: ResolvedPos\n}\n\nexport type SuggestionMatch = {\n range: Range\n query: string\n text: string\n} | null\n\nexport function findSuggestionMatch(config: Trigger): SuggestionMatch {\n const {\n char,\n allowSpaces: allowSpacesOption,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position,\n } = config\n\n const allowSpaces = allowSpacesOption && !allowToIncludeChar\n\n const escapedChar = escapeForRegEx(char)\n const suffix = new RegExp(`\\\\s${escapedChar}$`)\n const prefix = startOfLine ? '^' : ''\n const finalEscapedChar = allowToIncludeChar ? '' : escapedChar\n const regexp = allowSpaces\n ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, 'gm')\n : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, 'gm')\n\n const text = $position.nodeBefore?.isText && $position.nodeBefore.text\n\n if (!text) {\n return null\n }\n\n const textFrom = $position.pos - text.length\n const match = Array.from(text.matchAll(regexp)).pop()\n\n if (!match || match.input === undefined || match.index === undefined) {\n return null\n }\n\n // JavaScript doesn't have lookbehinds. This hacks a check that first character\n // is a space or the start of the line\n const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)\n const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\\0]?$`).test(matchPrefix)\n\n if (allowedPrefixes !== null && !matchPrefixIsAllowed) {\n return null\n }\n\n // The absolute position of the match in the document\n const from = textFrom + match.index\n let to = from + match[0].length\n\n // Edge case handling; if spaces are allowed and we're directly in between\n // two triggers\n if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n match[0] += ' '\n to += 1\n }\n\n // If the $position is located within the matched substring, return that range\n if (from < $position.pos && to >= $position.pos) {\n return {\n range: {\n from,\n to,\n },\n query: match[0].slice(char.length),\n text: match[0],\n }\n }\n\n return null\n}\n"],"mappings":"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","names":["findSuggestionMatch"]}
{"version":3,"sources":["../src/index.ts","../src/suggestion.ts","../src/findSuggestionMatch.ts","../src/helpers.ts","../src/plugin/props.ts","../src/plugin/state.ts","../src/plugin/async.ts","../src/plugin/floating-ui.ts","../src/plugin/view.ts"],"sourcesContent":["import { exitSuggestion, Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport { exitSuggestion }\n\nexport default Suggestion\n","import type { Range } from '@tiptap/core'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport type { SuggestionMatch } from './findSuggestionMatch.js'\nimport { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'\nimport {\n clientRectFor as clientRectForHelper,\n dispatchExit as dispatchExitHelper,\n shouldKeepDismissed as shouldKeepDismissedHelper,\n} from './helpers.js'\nimport { createSuggestionProps } from './plugin/props.js'\nimport { createSuggestionState } from './plugin/state.js'\nimport { createSuggestionView } from './plugin/view.js'\nimport type { SuggestionOptions } from './types.js'\n\nexport type {\n SuggestionFloatingUiConfig,\n SuggestionFloatingUiOptions,\n SuggestionKeyDownProps,\n SuggestionMount,\n SuggestionMountOptions,\n SuggestionOptions,\n SuggestionPlacement,\n SuggestionPositionData,\n SuggestionProps,\n} from './types.js'\n\nexport const SuggestionPluginKey = new PluginKey('suggestion')\n\ntype ShouldKeepDismissedProps = {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n}\n\n/**\n * This utility allows you to create suggestions.\n * @see https://tiptap.dev/api/utilities/suggestion\n */\nexport function Suggestion<I = any, TSelected = any>({\n pluginKey = SuggestionPluginKey,\n editor,\n char = '@',\n allowSpaces = false,\n allowToIncludeChar = false,\n allowedPrefixes = [' '],\n startOfLine = false,\n decorationTag = 'span',\n decorationClass = 'suggestion',\n decorationContent = '',\n decorationEmptyClass = 'is-empty',\n command = () => null,\n items = () => [],\n minQueryLength = 0,\n debounce = 0,\n initialItems,\n placement = 'bottom-start',\n offset: offsetOption = {},\n container,\n flip = true,\n floatingUi,\n dismissOnOutsideClick = true,\n render = () => ({}),\n allow = () => true,\n findSuggestionMatch = defaultFindSuggestionMatch,\n shouldShow,\n shouldResetDismissed,\n}: SuggestionOptions<I, TSelected>) {\n const renderer = render?.()\n const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar\n\n const clientRectFor = (view: EditorView, decorationNode: Element | null) =>\n clientRectForHelper(editor, view, decorationNode, pluginKey)\n\n // helper to check if the dismissed suggestion should stay dismissed, with access to editor and options\n function shouldKeepDismissed(props: ShouldKeepDismissedProps) {\n return shouldKeepDismissedHelper({\n ...props,\n editor,\n shouldResetDismissed,\n effectiveAllowSpaces,\n })\n }\n\n const dispatchExit = (view: EditorView) =>\n dispatchExitHelper({\n view,\n pluginKeyRef: pluginKey,\n })\n\n return new Plugin({\n key: pluginKey,\n\n view: () =>\n createSuggestionView({\n editor,\n pluginKey,\n items,\n renderer,\n minQueryLength,\n debounce,\n initialItems,\n placement,\n offset: offsetOption,\n container,\n flip,\n floatingUi,\n dismissOnOutsideClick,\n command,\n clientRectFor,\n dispatchExit,\n }),\n\n state: createSuggestionState({\n editor,\n char,\n effectiveAllowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n findSuggestionMatch,\n allow,\n shouldShow,\n shouldKeepDismissed,\n pluginKey,\n }),\n\n props: createSuggestionProps({\n pluginKey,\n decorationTag,\n decorationClass,\n decorationContent,\n decorationEmptyClass,\n renderer,\n dispatchExit,\n }),\n })\n}\n\n/**\n * Programmatically exit a suggestion plugin by dispatching a metadata-only\n * transaction. This is the safe, recommended API to remove suggestion\n * decorations without touching the document or causing mapping errors.\n */\nexport function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { Range } from '@tiptap/core'\nimport { escapeForRegEx } from '@tiptap/core'\nimport type { ResolvedPos } from '@tiptap/pm/model'\n\nexport interface Trigger {\n char: string\n allowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n $position: ResolvedPos\n}\n\nexport type SuggestionMatch = {\n range: Range\n query: string\n text: string\n} | null\n\nexport function findSuggestionMatch(config: Trigger): SuggestionMatch {\n const {\n char,\n allowSpaces: allowSpacesOption,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position,\n } = config\n\n const allowSpaces = allowSpacesOption && !allowToIncludeChar\n\n const escapedChar = escapeForRegEx(char)\n const suffix = new RegExp(`\\\\s${escapedChar}$`)\n const prefix = startOfLine ? '^' : ''\n const finalEscapedChar = allowToIncludeChar ? '' : escapedChar\n const regexp = allowSpaces\n ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, 'gm')\n : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, 'gm')\n\n const text = $position.nodeBefore?.isText && $position.nodeBefore.text\n\n if (!text) {\n return null\n }\n\n const textFrom = $position.pos - text.length\n const match = Array.from(text.matchAll(regexp)).pop()\n\n if (!match || match.input === undefined || match.index === undefined) {\n return null\n }\n\n // JavaScript doesn't have lookbehinds. This hacks a check that first character\n // is a space or the start of the line\n const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)\n const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\\0]?$`).test(matchPrefix)\n\n if (allowedPrefixes !== null && !matchPrefixIsAllowed) {\n return null\n }\n\n // The absolute position of the match in the document\n const from = textFrom + match.index\n let to = from + match[0].length\n\n // Edge case handling; if spaces are allowed and we're directly in between\n // two triggers\n if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n match[0] += ' '\n to += 1\n }\n\n // If the $position is located within the matched substring, return that range\n if (from < $position.pos && to >= $position.pos) {\n return {\n range: {\n from,\n to,\n },\n query: match[0].slice(char.length),\n text: match[0],\n }\n }\n\n return null\n}\n","import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport type { SuggestionMatch } from './findSuggestionMatch.js'\nimport type { SuggestionOptions, SuggestionPluginState } from './types.js'\n\n/**\n * Returns true if the transaction inserted any whitespace or newline character.\n * Used to determine when a dismissed suggestion should become active again.\n */\nexport function hasInsertedWhitespace(transaction: Transaction): boolean {\n if (!transaction.docChanged) {\n return false\n }\n return transaction.steps.some(step => {\n const slice = (step as any).slice\n if (!slice?.content) {\n return false\n }\n // textBetween with '\\n' as block separator catches both inline spaces and newlines\n const inserted = slice.content.textBetween(0, slice.content.size, '\\n')\n return /\\s/.test(inserted)\n })\n}\n\n/**\n * Gets the DOM rectangle corresponding to the current editor cursor anchor position.\n * Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object.\n */\nexport function getAnchorClientRect(editor: Editor): () => DOMRect | null {\n return () => {\n const pos = editor.state.selection.$anchor.pos\n const coords = editor.view.coordsAtPos(pos)\n const { top, right, bottom, left } = coords\n\n try {\n return new DOMRect(left, top, right - left, bottom - top)\n } catch {\n return null\n }\n }\n}\n\n/**\n * Creates a clientRect callback for a given decoration node.\n * Returns the anchor rect when no decoration node is present.\n * Uses the pluginKey's state to resolve the current decoration node on demand.\n */\nexport function clientRectFor(\n editor: Editor,\n view: EditorView,\n decorationNode: Element | null,\n pluginKey: PluginKey,\n): () => DOMRect | null {\n if (!decorationNode) {\n return getAnchorClientRect(editor)\n }\n\n return () => {\n const state: SuggestionPluginState = pluginKey.getState(editor.state) as any\n const decorationId = state?.decorationId\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n}\n\n/**\n * Determines whether a dismissed suggestion should stay dismissed.\n * Returns `true` (keep dismissed) or `false` (allow reactivation).\n */\nexport function shouldKeepDismissed({\n match,\n dismissedRange,\n state,\n transaction,\n editor,\n shouldResetDismissed,\n effectiveAllowSpaces,\n}: {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n editor: Editor\n shouldResetDismissed?: SuggestionOptions['shouldResetDismissed']\n effectiveAllowSpaces: boolean\n}): boolean {\n if (\n shouldResetDismissed?.({\n editor,\n state,\n range: dismissedRange,\n match,\n transaction,\n allowSpaces: effectiveAllowSpaces,\n })\n ) {\n return false\n }\n\n if (effectiveAllowSpaces) {\n return match.range.from === dismissedRange.from\n }\n\n return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)\n}\n\n/**\n * Dispatch an exit of the suggestion plugin by dispatching a metadata-only\n * transaction to clear the plugin state. The renderer's onExit hook is NOT\n * called here — it fires via the plugin view's stopped transition, which\n * builds SuggestionProps consistently with the normal lifecycle.\n *\n * This prevents a double onExit call (one from dispatchExit, one from the\n * view's update) and keeps exitSuggestion consistent with Escape-triggered\n * exits.\n */\nexport function dispatchExit({\n view,\n pluginKeyRef,\n}: {\n view: EditorView\n pluginKeyRef: PluginKey\n}): void {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { EditorState, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nimport type { SuggestionKeyDownProps, SuggestionPluginState } from '../types.js'\n\n/**\n * Creates the `props` object for the suggestion ProseMirror plugin.\n * Contains `handleKeyDown` for keyboard handling and `decorations`\n * for rendering the suggestion highlight.\n */\nexport interface CreateSuggestionPropsOptions {\n pluginKey: PluginKey\n decorationTag: string\n decorationClass: string\n decorationContent: string\n decorationEmptyClass: string\n renderer: { onKeyDown?: (props: SuggestionKeyDownProps) => boolean } | undefined\n dispatchExit: (view: EditorView) => void\n}\n\n/**\n * Creates the `props` object for the suggestion ProseMirror plugin.\n * Contains `handleKeyDown` for keyboard handling and `decorations`\n * for rendering the suggestion highlight.\n */\nexport function createSuggestionProps({\n pluginKey,\n decorationTag,\n decorationClass,\n decorationContent,\n decorationEmptyClass,\n renderer,\n dispatchExit,\n}: CreateSuggestionPropsOptions) {\n return {\n /**\n * Call the keydown hook if suggestion is active.\n */\n handleKeyDown(view: EditorView, event: KeyboardEvent) {\n const state: SuggestionPluginState = pluginKey.getState(view.state) as any\n\n if (!state.active) {\n return false\n }\n\n // If Escape is pressed, call onKeyDown and dispatch a metadata-only\n // transaction to unset the suggestion state. This provides a safe\n // and deterministic way to exit the suggestion without altering the\n // document (avoids transaction mapping/mismatch issues).\n if (event.key === 'Escape' || event.key === 'Esc') {\n // Allow the consumer to react to Escape, but always clear the\n // suggestion state afterward so the decoration is removed too.\n renderer?.onKeyDown?.({ view, event, range: state.range })\n\n // dispatch metadata-only transaction to unset the plugin state\n dispatchExit(view)\n\n return true\n }\n\n const handled = renderer?.onKeyDown?.({ view, event, range: state.range }) || false\n return handled\n },\n\n /**\n * Setup decorator on the currently active suggestion.\n */\n decorations(state: EditorState) {\n const pluginState: SuggestionPluginState = pluginKey.getState(state) as any\n const { active, range, decorationId, query } = pluginState\n\n if (!active) {\n return null\n }\n\n const isEmpty = !query?.length\n const classNames = [decorationClass]\n\n if (isEmpty) {\n classNames.push(decorationEmptyClass)\n }\n\n return DecorationSet.create(state.doc, [\n Decoration.inline(range.from, range.to, {\n nodeName: decorationTag,\n class: classNames.join(' '),\n 'data-decoration-id': decorationId || undefined,\n 'data-decoration-content': decorationContent,\n }),\n ])\n },\n }\n}\n","import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'\n\nimport type {\n findSuggestionMatch as defaultFindSuggestionMatch,\n SuggestionMatch,\n} from '../findSuggestionMatch.js'\nimport type { SuggestionOptions, SuggestionPluginState } from '../types.js'\n\nexport interface CreateSuggestionStateOptions {\n editor: Editor\n char: string\n effectiveAllowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n findSuggestionMatch: typeof defaultFindSuggestionMatch\n allow: Exclude<SuggestionOptions['allow'], undefined>\n shouldShow?: SuggestionOptions['shouldShow']\n shouldKeepDismissed: (props: {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n }) => boolean\n pluginKey: PluginKey\n}\n\n/**\n * Creates the `state` object for the suggestion ProseMirror plugin.\n * Contains `init()` and `apply()` for managing the plugin's internal state\n * across transactions.\n */\nexport function createSuggestionState({\n editor,\n char,\n effectiveAllowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n findSuggestionMatch,\n allow,\n shouldShow,\n shouldKeepDismissed,\n pluginKey,\n}: CreateSuggestionStateOptions) {\n return {\n /**\n * Initialize the plugin's internal state.\n */\n init(): SuggestionPluginState {\n return {\n active: false,\n range: { from: 0, to: 0 },\n query: null,\n text: null,\n composing: false,\n dismissedRange: null,\n }\n },\n\n /**\n * Apply changes to the plugin state from a view transaction.\n */\n apply(\n transaction: Transaction,\n prev: SuggestionPluginState,\n _oldState: EditorState,\n state: EditorState,\n ): SuggestionPluginState {\n const { isEditable } = editor\n const { composing } = editor.view\n const { selection } = transaction\n const { empty, from } = selection\n const next = { ...prev }\n\n // If a transaction carries the exit meta for this plugin, immediately\n // deactivate the suggestion. This allows metadata-only transactions\n // (dispatched by escape or programmatic exit) to deterministically\n // clear decorations without changing the document.\n const meta = transaction.getMeta(pluginKey)\n if (meta && meta.exit) {\n next.active = false\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange\n\n return next\n }\n\n next.composing = composing\n\n if (transaction.docChanged && next.dismissedRange !== null) {\n next.dismissedRange = {\n from: transaction.mapping.map(next.dismissedRange.from),\n to: transaction.mapping.map(next.dismissedRange.to),\n }\n }\n\n // We can only be suggesting if the view is editable, and:\n // * there is no selection, or\n // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)\n if (isEditable && (empty || editor.view.composing)) {\n // Reset active state if we just left the previous suggestion range\n if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {\n next.active = false\n }\n\n // Try to match against where our cursor currently is\n const match = findSuggestionMatch({\n char,\n allowSpaces: effectiveAllowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position: selection.$from,\n })\n const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`\n\n // If we found a match, update the current state to show it\n if (\n match &&\n allow({\n editor,\n state,\n range: match.range,\n isActive: prev.active,\n }) &&\n (!shouldShow ||\n shouldShow({\n editor,\n range: match.range,\n query: match.query,\n text: match.text,\n transaction,\n }))\n ) {\n if (\n next.dismissedRange !== null &&\n !shouldKeepDismissed({\n match,\n dismissedRange: next.dismissedRange,\n state,\n transaction,\n })\n ) {\n next.dismissedRange = null\n }\n\n if (next.dismissedRange === null) {\n next.active = true\n next.decorationId = prev.decorationId || decorationId\n next.range = match.range\n next.query = match.query\n next.text = match.text\n } else {\n next.active = false\n }\n } else {\n if (!match) {\n next.dismissedRange = null\n }\n next.active = false\n }\n } else {\n next.active = false\n }\n\n // Make sure to empty the range if suggestion is inactive\n if (!next.active) {\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n }\n\n return next\n },\n }\n}\n","import type { Editor } from '@tiptap/core'\n\nimport type { SuggestionOptions } from '../types.js'\n\nexport interface CreateSuggestionAsyncRequestManagerOptions<I = any> {\n editor: Editor\n items: NonNullable<SuggestionOptions<I>['items']>\n}\n\ntype AsyncRequestResult<I> =\n | { status: 'resolved'; items: I[] }\n | { status: 'aborted' }\n | { status: 'error' }\n\nexport function createSuggestionAsyncRequestManager<I = any>({\n editor,\n items,\n}: CreateSuggestionAsyncRequestManagerOptions<I>) {\n let abortController: AbortController | null = null\n let debounceTimer: ReturnType<typeof setTimeout> | null = null\n let debounceResolve: (() => void) | null = null\n\n const clearDebounceTimer = () => {\n if (debounceTimer !== null) {\n clearTimeout(debounceTimer)\n debounceTimer = null\n }\n\n debounceResolve?.()\n debounceResolve = null\n }\n\n const waitForDebounce = (delay: number) => {\n return new Promise<void>(resolve => {\n debounceResolve = resolve\n debounceTimer = setTimeout(() => {\n debounceTimer = null\n const pendingResolve = debounceResolve\n debounceResolve = null\n pendingResolve?.()\n }, delay)\n })\n }\n\n const abort = () => {\n abortController?.abort()\n clearDebounceTimer()\n abortController = null\n }\n\n const fetch = async (query: string, debounce: number): Promise<AsyncRequestResult<I>> => {\n abort()\n abortController = new AbortController()\n const controller = abortController\n\n if (debounce > 0) {\n await waitForDebounce(debounce)\n }\n\n if (abortController !== controller || controller.signal.aborted) {\n return { status: 'aborted' }\n }\n\n try {\n const result = await items({\n editor,\n query,\n signal: controller.signal,\n })\n\n if (abortController !== controller || controller.signal.aborted) {\n return { status: 'aborted' }\n }\n\n return { status: 'resolved', items: result }\n } catch {\n if (abortController !== controller || controller.signal.aborted) {\n return { status: 'aborted' }\n }\n\n return { status: 'error' }\n }\n }\n\n return {\n abort,\n fetch,\n }\n}\n","import type { Middleware, VirtualElement } from '@floating-ui/dom'\nimport {\n autoUpdate,\n computePosition,\n flip as floatingUiFlip,\n offset as floatingUiOffset,\n} from '@floating-ui/dom'\n\nimport type {\n SuggestionFloatingUiConfig,\n SuggestionFloatingUiOptions,\n SuggestionMount,\n SuggestionPlacement,\n} from '../types.js'\n\nexport interface CreateSuggestionFloatingUiConfigOptions {\n placement: SuggestionPlacement\n offset: { mainAxis?: number; crossAxis?: number }\n flip: boolean\n floatingUi?: SuggestionFloatingUiOptions\n}\n\nexport function createSuggestionFloatingUiConfig({\n placement,\n offset,\n flip,\n floatingUi,\n}: CreateSuggestionFloatingUiConfigOptions): SuggestionFloatingUiConfig {\n const middleware: Middleware[] = [\n floatingUiOffset({\n mainAxis: offset.mainAxis ?? 4,\n crossAxis: offset.crossAxis ?? 0,\n }),\n ]\n\n if (flip) {\n middleware.push(floatingUiFlip())\n }\n\n if (floatingUi?.middleware?.length) {\n middleware.push(...floatingUi.middleware)\n }\n\n return {\n placement,\n strategy: floatingUi?.strategy ?? 'absolute',\n middleware,\n }\n}\n\nexport interface CreateMountOptions {\n /** Returns the current cursor/anchor rect the popup should track. */\n getReferenceRect: () => DOMRect | null\n /**\n * An element inside the editor's layout/scroll context. Floating UI walks up\n * from here to discover the scroll ancestors (and the window) to observe, so\n * the scroll container does not need to be configured manually.\n */\n contextElement: Element\n /** Resolved Floating UI config (placement, strategy, middleware). */\n config: SuggestionFloatingUiConfig\n /**\n * CSS selector or element the popup should be mounted into. Defaults to\n * `document.body`. Used to portal the popup inside dialogs/modals so it\n * renders on top of (and clips within) the right context.\n */\n container?: string | HTMLElement\n /**\n * When `true`, a pointerdown outside both the popup and the editor dismisses\n * the suggestion. Wired up and torn down alongside the mounted element.\n */\n dismissOnOutsideClick: boolean\n /** Dismisses the active suggestion (used by outside-click handling). */\n dismiss: () => void\n}\n\n/**\n * Resolves a container option (selector or element) to a mount target,\n * falling back to `document.body` when it can't be resolved.\n */\nfunction resolveContainer(container?: string | HTMLElement): HTMLElement {\n if (container instanceof HTMLElement) {\n return container\n }\n\n if (typeof container === 'string') {\n try {\n // `container` is consumer-provided; an invalid selector throws a\n // DOMException, so fall back to document.body instead of crashing.\n const found = document.querySelector<HTMLElement>(container)\n\n if (found) {\n return found\n }\n } catch {\n return document.body\n }\n }\n\n return document.body\n}\n\n/**\n * Builds the `mount` function handed to the renderer on `SuggestionProps`.\n *\n * Mounts the popup into the container, then wires Floating UI's `autoUpdate`\n * against a virtual reference that re-reads the live cursor rect, so the popup\n * stays anchored across scroll, resize, and layout shifts without the consumer\n * attaching any listeners. The returned `unmount` tears all of that down.\n */\nexport function createMount({\n getReferenceRect,\n contextElement,\n config,\n container,\n dismissOnOutsideClick,\n dismiss,\n}: CreateMountOptions): SuggestionMount {\n return (element, options = {}) => {\n const reference: VirtualElement = {\n getBoundingClientRect: () => getReferenceRect() ?? new DOMRect(),\n contextElement,\n }\n\n let positioned = false\n\n // Mount the popup into the container (default `document.body`) unless the\n // consumer already placed it in the DOM themselves — in which case we leave\n // mounting (and unmounting) to them.\n const mountedByUs = !element.isConnected\n\n if (mountedByUs) {\n resolveContainer(container).appendChild(element)\n }\n\n // Hide the element until the first measurement resolves so it doesn't flash\n // at its initial coordinates. Skipped when the consumer owns applying the\n // position via `onPosition`.\n if (!options.onPosition) {\n element.style.visibility = 'hidden'\n element.style.width = 'max-content'\n }\n\n const update = () => {\n computePosition(reference, element, {\n placement: config.placement,\n strategy: config.strategy,\n middleware: config.middleware,\n }).then(({ x, y, placement, strategy }) => {\n if (options.onPosition) {\n options.onPosition({ x, y, placement: placement as SuggestionPlacement, strategy })\n return\n }\n\n Object.assign(element.style, {\n position: strategy,\n left: `${x}px`,\n top: `${y}px`,\n })\n\n if (!positioned) {\n positioned = true\n element.style.visibility = ''\n }\n })\n }\n\n const cleanupAutoUpdate = autoUpdate(reference, element, update, options.autoUpdate)\n\n // Dismiss when the user interacts outside both the popup and the editor.\n // Capture phase so a parent that stops propagation can't swallow it.\n let onOutsidePointerDown: ((event: PointerEvent) => void) | undefined\n\n if (dismissOnOutsideClick) {\n onOutsidePointerDown = event => {\n const target = event.target\n\n if (\n !(target instanceof Node) ||\n element.contains(target) ||\n contextElement.contains(target)\n ) {\n return\n }\n\n dismiss()\n }\n\n document.addEventListener('pointerdown', onOutsidePointerDown, true)\n }\n\n return () => {\n cleanupAutoUpdate()\n\n if (onOutsidePointerDown) {\n document.removeEventListener('pointerdown', onOutsidePointerDown, true)\n }\n\n if (mountedByUs) {\n element.remove()\n }\n }\n }\n}\n","import type { Editor } from '@tiptap/core'\nimport type { EditorState, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport type {\n PluginState,\n SuggestionFloatingUiOptions,\n SuggestionOptions,\n SuggestionPlacement,\n SuggestionProps,\n} from '../types.js'\nimport { createSuggestionAsyncRequestManager } from './async.js'\nimport { createMount, createSuggestionFloatingUiConfig } from './floating-ui.js'\n\nexport interface CreateSuggestionViewOptions {\n editor: Editor\n pluginKey: PluginKey<PluginState>\n items: NonNullable<SuggestionOptions['items']>\n renderer: ReturnType<NonNullable<SuggestionOptions['render']>> | undefined\n minQueryLength: number\n debounce: number\n initialItems?: any[]\n placement: SuggestionPlacement\n offset: { mainAxis?: number; crossAxis?: number }\n container?: string | HTMLElement\n flip: boolean\n floatingUi?: SuggestionFloatingUiOptions\n dismissOnOutsideClick: boolean\n command: NonNullable<SuggestionOptions['command']>\n clientRectFor: (view: EditorView, decorationNode: Element | null) => () => DOMRect | null\n dispatchExit: (view: EditorView) => void\n}\n\n/**\n * Creates the `view` object for the suggestion ProseMirror plugin.\n *\n * Manages the async lifecycle: tracks state transitions, calls renderer hooks,\n * fetches items with debounce and AbortController support.\n *\n * 1. Tracks plugin state transitions (started, updated, stopped) to determine when to call renderer hooks.\n * 2. Calls `onBeforeStart`, `onBeforeUpdate`, `onStart` before fetching to allow the renderer to prepare for first render\n * 3. Manages async fetching of suggestion items with support for debouncing and aborting in-flight requests\n * 4. Calls `onUpdate` after fetching new items to update the renderer with the latest data\n * 5. At the end calls a final `onExit` or `onUpdate` to allow the renderer to clean up or finalize the state\n */\nexport function createSuggestionView({\n editor,\n pluginKey,\n items,\n renderer,\n minQueryLength,\n debounce,\n initialItems,\n placement,\n offset: offsetOption,\n container,\n flip,\n floatingUi,\n dismissOnOutsideClick,\n command,\n clientRectFor,\n dispatchExit,\n}: CreateSuggestionViewOptions) {\n let props: SuggestionProps | undefined\n const asyncRequest = createSuggestionAsyncRequestManager({\n editor,\n items,\n })\n const floatingUiConfig = createSuggestionFloatingUiConfig({\n placement,\n offset: offsetOption,\n flip,\n floatingUi,\n })\n\n function dispatchStateUpdate(\n state: 'started' | 'updated' | 'stopped',\n dispatchProps: SuggestionProps,\n ) {\n switch (state) {\n case 'started':\n renderer?.onStart?.(dispatchProps)\n break\n case 'updated':\n renderer?.onUpdate?.(dispatchProps)\n break\n case 'stopped':\n renderer?.onExit?.(dispatchProps)\n break\n default:\n break\n }\n }\n\n return {\n update: async (view: EditorView, prevState: EditorState) => {\n const prev = pluginKey.getState(prevState)\n const next = pluginKey.getState(view.state)\n\n if (!prev || !next) {\n return\n }\n\n let currentState: 'started' | 'updated' | 'stopped' | null = null\n const queryChanged = prev.query !== next.query\n const textChanged = prev.text !== next.text\n const rangeChanged = prev.range.from !== next.range.from || prev.range.to !== next.range.to\n const effectiveQueryChanged = queryChanged || textChanged || rangeChanged\n\n if (!prev.active && next.active) {\n currentState = 'started'\n } else if (prev.active && !next.active) {\n currentState = 'stopped'\n } else if (next.active && effectiveQueryChanged) {\n currentState = 'updated'\n } else {\n return\n }\n\n const state = currentState === 'stopped' ? prev : next\n const decorationNode = view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n const clientRect = clientRectFor(view, decorationNode)\n\n const exceedsMinQueryLength =\n minQueryLength === 0 || (state.query ? state.query.length >= minQueryLength : false)\n const willFetch =\n (currentState === 'started' || currentState === 'updated') && exceedsMinQueryLength\n\n props = {\n editor,\n range: state.range,\n query: state.query || '',\n text: state.text || '',\n items: initialItems ?? [],\n command: commandProps => {\n return command({\n editor,\n range: state.range,\n props: commandProps,\n })\n },\n decorationNode,\n clientRect,\n loading: willFetch,\n placement,\n offset: { mainAxis: offsetOption.mainAxis ?? 4, crossAxis: offsetOption.crossAxis ?? 0 },\n container,\n flip,\n floatingUi: floatingUiConfig,\n mount: createMount({\n getReferenceRect: clientRect,\n contextElement: view.dom,\n config: floatingUiConfig,\n container,\n dismissOnOutsideClick,\n dismiss: () => dispatchExit(editor.view),\n }),\n }\n\n if (currentState === 'started') {\n renderer?.onBeforeStart?.(props)\n }\n\n if (currentState === 'updated') {\n renderer?.onBeforeUpdate?.(props)\n }\n\n // we run the start before we fetch\n // to allow for the component to render immediately\n if (currentState === 'started') {\n dispatchStateUpdate(currentState, props)\n }\n\n if (currentState === 'started' || currentState === 'updated') {\n if (!willFetch) {\n // Abort any in-flight request so stale results don't overwrite\n asyncRequest.abort()\n props = { ...props, items: initialItems ?? [], loading: false }\n } else {\n // update the renderer with loading state before we start the async fetch\n props = { ...props, items: initialItems ?? [], loading: true }\n currentState = 'updated'\n dispatchStateUpdate(currentState, props)\n\n const result = await asyncRequest.fetch(state.query || '', debounce)\n\n if (result.status === 'aborted') {\n return\n }\n\n // Re-check plugin state because the suggestion may have been dismissed\n const currentPluginState = pluginKey.getState(view.state)\n if (!currentPluginState?.active) {\n asyncRequest.abort()\n\n return\n }\n\n props =\n result.status === 'resolved'\n ? {\n ...props,\n items: result.items,\n loading: false,\n }\n : {\n ...props,\n loading: false,\n }\n }\n }\n\n if (currentState === 'stopped') {\n // stop running updates immediately and call onExit to allow the renderer to clean up\n asyncRequest.abort()\n dispatchStateUpdate(currentState, props)\n props = undefined\n return\n }\n\n if (currentState === 'updated') {\n dispatchStateUpdate(currentState, props)\n }\n },\n\n destroy: () => {\n asyncRequest.abort()\n\n if (!props) {\n return\n }\n\n renderer?.onExit?.(props)\n },\n }\n}\n"],"mappings":"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","names":["dispatchExit","findSuggestionMatch","shouldKeepDismissed","floatingUiOffset","floatingUiFlip","clientRectFor","dispatchExit","findSuggestionMatch","clientRectFor","shouldKeepDismissed","dispatchExit"]}

@@ -0,1 +1,2 @@

import { Middleware, AutoUpdateOptions } from '@floating-ui/dom';
import { Range, Editor } from '@tiptap/core';

@@ -21,2 +22,52 @@ import { PluginKey, Transaction, EditorState, Plugin } from '@tiptap/pm/state';

type SuggestionPlacement = 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end';
type SuggestionFloatingUiOptions = {
strategy?: 'absolute' | 'fixed';
middleware?: Middleware[];
};
type SuggestionFloatingUiConfig = {
placement: SuggestionPlacement;
strategy: 'absolute' | 'fixed';
middleware: Middleware[];
};
/**
* The computed position handed to a custom `onPosition` callback when using
* managed positioning via {@link SuggestionProps.mount}.
*/
type SuggestionPositionData = {
x: number;
y: number;
placement: SuggestionPlacement;
strategy: 'absolute' | 'fixed';
};
/**
* Options for managed mounting + positioning via {@link SuggestionProps.mount}.
*/
type SuggestionMountOptions = {
/**
* Override how the computed position is applied to the element.
* When provided, the plugin stops writing `style.left`/`style.top` itself and
* hands you the computed coordinates so you can apply them however you want
* (custom transforms, animation, writing to a framework ref, etc.).
*/
onPosition?: (data: SuggestionPositionData) => void;
/**
* Options forwarded to Floating UI's `autoUpdate`. Use this to opt into
* `animationFrame` polling for anchors that move inside transformed or
* animated containers, or to disable specific observers.
* @see https://floating-ui.com/docs/autoUpdate
*/
autoUpdate?: AutoUpdateOptions;
};
/**
* Mounts a floating element and takes over its positioning. The plugin appends
* the element into the configured `container` (default `document.body`), keeps
* it anchored to the suggestion's cursor rect, and automatically repositions it
* on scroll, resize, and layout shifts via Floating UI's `autoUpdate` — no
* manual listeners required.
*
* Returns an `unmount` function that tears down the listeners and removes the
* element (when the plugin mounted it). Call it from `onExit`.
*/
type SuggestionMount = (element: HTMLElement, options?: SuggestionMountOptions) => () => void;
interface SuggestionOptions<I = any, TSelected = any> {

@@ -133,2 +184,63 @@ /**

/**
* Minimum query length before `items()` is called.
* When the query is shorter, empty items are passed to the renderer.
* @default 0 (no filter, same as before)
* @example 2
*/
minQueryLength?: number;
/**
* Debounce in milliseconds. When set, `items()` will only be called
* after the user stops typing for this duration.
* @default 0 (no debounce, same as before)
* @example 300
*/
debounce?: number;
/**
* Items shown immediately when the suggestion popup opens,
* before the async `items()` call resolves.
* Useful for showing recent or popular items while loading.
* @default undefined (no pre-populated items)
*/
initialItems?: I[];
/**
* Placement of the popup relative to the cursor.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default 'bottom-start'
*/
placement?: SuggestionPlacement;
/**
* Offset of the popup in pixels.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default { mainAxis: 4, crossAxis: 0 }
*/
offset?: {
mainAxis?: number;
crossAxis?: number;
};
/**
* CSS selector or element that defines the containment context for the popup.
* Consumers can read this from `SuggestionProps` when rendering inside modals or dialogs.
* @default undefined (no containment)
*/
container?: string | HTMLElement;
/**
* Whether the popup should automatically flip when there isn't enough space.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default true
*/
flip?: boolean;
/**
* Additional Floating UI options and middleware passed through to the renderer.
* The plugin keeps ownership of the anchor and placement, but consumers can
* append custom middleware here.
*/
floatingUi?: SuggestionFloatingUiOptions;
/**
* Dismiss the suggestion when the user interacts outside both the popup and
* the editor. Only applies when using managed mounting via
* {@link SuggestionProps.mount} (the plugin needs to know the popup element).
* @default true
*/
dismissOnOutsideClick?: boolean;
/**
* A function that returns the suggestion items in form of an array.

@@ -144,2 +256,3 @@ * @param props The props object.

editor: Editor;
signal: AbortSignal;
}) => I[] | Promise<I[]>;

@@ -171,2 +284,5 @@ /**

}
/**
* The props passed to the suggestion's render functions (onStart, onUpdate, onExit).
*/
interface SuggestionProps<I = any, TSelected = any> {

@@ -210,3 +326,67 @@ /**

clientRect?: (() => DOMRect | null) | null;
/**
* Placement of the popup relative to the cursor.
* @default 'bottom-start'
*/
placement: SuggestionPlacement;
/**
* Offset of the popup in pixels.
* @default { mainAxis: 4, crossAxis: 0 }
*/
offset: {
mainAxis: number;
crossAxis: number;
};
/**
* CSS selector or element that defines the containment context for the popup.
* @default undefined
*/
container?: string | HTMLElement;
/**
* Whether the popup should automatically flip when there isn't enough space.
* @default true
*/
flip: boolean;
/**
* Resolved Floating UI config for direct use with `computePosition()`.
* This is the escape hatch: reach for it only when you mount the element
* yourself and want to run the positioning loop manually instead of using
* {@link SuggestionProps.mount}.
*/
floatingUi: SuggestionFloatingUiConfig;
/**
* Mounts your floating element and takes over positioning — the recommended,
* default way to render a suggestion popup.
*
* Pass the element you rendered (e.g. from `ReactRenderer`/`VueRenderer`). The
* plugin appends it into the configured `container` (default `document.body`),
* keeps it anchored to the cursor, and repositions it on scroll, resize, and
* layout shifts — no manual listeners required. Returns an `unmount` function
* that tears everything down; call it in `onExit`.
*
* Escape hatch: mount the element yourself and skip this, then run your own
* positioning loop with {@link SuggestionProps.floatingUi} +
* {@link SuggestionProps.clientRect}.
*
* @example
* ```ts
* onStart: props => {
* component = new ReactRenderer(DropdownList, { props, editor: props.editor })
* unmount = props.mount(component.element)
* },
* onExit: () => unmount?.(),
* ```
*/
mount: SuggestionMount;
/**
* Whether the items are currently being loaded.
* `true` before the async `items()` call resolves.
* Useful for showing a loading spinner or skeleton.
* @default false
*/
loading: boolean;
}
/**
* The props passed to the suggestion's onKeyDown render function
*/
interface SuggestionKeyDownProps {

@@ -217,2 +397,13 @@ view: EditorView;

}
/** @internal Internal state shape for the suggestion plugin. */
interface SuggestionPluginState {
active: boolean;
range: Range;
query: null | string;
text: null | string;
composing: boolean;
decorationId?: string | null;
dismissedRange: Range | null;
}
declare const SuggestionPluginKey: PluginKey<any>;

@@ -223,3 +414,3 @@ /**

*/
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, shouldShow, shouldResetDismissed, }: SuggestionOptions<I, TSelected>): Plugin<any>;
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, minQueryLength, debounce, initialItems, placement, offset: offsetOption, container, flip, floatingUi, dismissOnOutsideClick, render, allow, findSuggestionMatch, shouldShow, shouldResetDismissed, }: SuggestionOptions<I, TSelected>): Plugin<SuggestionPluginState>;
/**

@@ -232,2 +423,2 @@ * Programmatically exit a suggestion plugin by dispatching a metadata-only

export { Suggestion, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionOptions, SuggestionPluginKey, type SuggestionProps, type Trigger, Suggestion as default, exitSuggestion, findSuggestionMatch };
export { Suggestion, type SuggestionFloatingUiConfig, type SuggestionFloatingUiOptions, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionMount, type SuggestionMountOptions, type SuggestionOptions, type SuggestionPlacement, SuggestionPluginKey, type SuggestionPositionData, type SuggestionProps, type Trigger, Suggestion as default, exitSuggestion, findSuggestionMatch };

@@ -0,1 +1,2 @@

import { Middleware, AutoUpdateOptions } from '@floating-ui/dom';
import { Range, Editor } from '@tiptap/core';

@@ -21,2 +22,52 @@ import { PluginKey, Transaction, EditorState, Plugin } from '@tiptap/pm/state';

type SuggestionPlacement = 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end';
type SuggestionFloatingUiOptions = {
strategy?: 'absolute' | 'fixed';
middleware?: Middleware[];
};
type SuggestionFloatingUiConfig = {
placement: SuggestionPlacement;
strategy: 'absolute' | 'fixed';
middleware: Middleware[];
};
/**
* The computed position handed to a custom `onPosition` callback when using
* managed positioning via {@link SuggestionProps.mount}.
*/
type SuggestionPositionData = {
x: number;
y: number;
placement: SuggestionPlacement;
strategy: 'absolute' | 'fixed';
};
/**
* Options for managed mounting + positioning via {@link SuggestionProps.mount}.
*/
type SuggestionMountOptions = {
/**
* Override how the computed position is applied to the element.
* When provided, the plugin stops writing `style.left`/`style.top` itself and
* hands you the computed coordinates so you can apply them however you want
* (custom transforms, animation, writing to a framework ref, etc.).
*/
onPosition?: (data: SuggestionPositionData) => void;
/**
* Options forwarded to Floating UI's `autoUpdate`. Use this to opt into
* `animationFrame` polling for anchors that move inside transformed or
* animated containers, or to disable specific observers.
* @see https://floating-ui.com/docs/autoUpdate
*/
autoUpdate?: AutoUpdateOptions;
};
/**
* Mounts a floating element and takes over its positioning. The plugin appends
* the element into the configured `container` (default `document.body`), keeps
* it anchored to the suggestion's cursor rect, and automatically repositions it
* on scroll, resize, and layout shifts via Floating UI's `autoUpdate` — no
* manual listeners required.
*
* Returns an `unmount` function that tears down the listeners and removes the
* element (when the plugin mounted it). Call it from `onExit`.
*/
type SuggestionMount = (element: HTMLElement, options?: SuggestionMountOptions) => () => void;
interface SuggestionOptions<I = any, TSelected = any> {

@@ -133,2 +184,63 @@ /**

/**
* Minimum query length before `items()` is called.
* When the query is shorter, empty items are passed to the renderer.
* @default 0 (no filter, same as before)
* @example 2
*/
minQueryLength?: number;
/**
* Debounce in milliseconds. When set, `items()` will only be called
* after the user stops typing for this duration.
* @default 0 (no debounce, same as before)
* @example 300
*/
debounce?: number;
/**
* Items shown immediately when the suggestion popup opens,
* before the async `items()` call resolves.
* Useful for showing recent or popular items while loading.
* @default undefined (no pre-populated items)
*/
initialItems?: I[];
/**
* Placement of the popup relative to the cursor.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default 'bottom-start'
*/
placement?: SuggestionPlacement;
/**
* Offset of the popup in pixels.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default { mainAxis: 4, crossAxis: 0 }
*/
offset?: {
mainAxis?: number;
crossAxis?: number;
};
/**
* CSS selector or element that defines the containment context for the popup.
* Consumers can read this from `SuggestionProps` when rendering inside modals or dialogs.
* @default undefined (no containment)
*/
container?: string | HTMLElement;
/**
* Whether the popup should automatically flip when there isn't enough space.
* Consumers can read this from `SuggestionProps` to configure their positioning library.
* @default true
*/
flip?: boolean;
/**
* Additional Floating UI options and middleware passed through to the renderer.
* The plugin keeps ownership of the anchor and placement, but consumers can
* append custom middleware here.
*/
floatingUi?: SuggestionFloatingUiOptions;
/**
* Dismiss the suggestion when the user interacts outside both the popup and
* the editor. Only applies when using managed mounting via
* {@link SuggestionProps.mount} (the plugin needs to know the popup element).
* @default true
*/
dismissOnOutsideClick?: boolean;
/**
* A function that returns the suggestion items in form of an array.

@@ -144,2 +256,3 @@ * @param props The props object.

editor: Editor;
signal: AbortSignal;
}) => I[] | Promise<I[]>;

@@ -171,2 +284,5 @@ /**

}
/**
* The props passed to the suggestion's render functions (onStart, onUpdate, onExit).
*/
interface SuggestionProps<I = any, TSelected = any> {

@@ -210,3 +326,67 @@ /**

clientRect?: (() => DOMRect | null) | null;
/**
* Placement of the popup relative to the cursor.
* @default 'bottom-start'
*/
placement: SuggestionPlacement;
/**
* Offset of the popup in pixels.
* @default { mainAxis: 4, crossAxis: 0 }
*/
offset: {
mainAxis: number;
crossAxis: number;
};
/**
* CSS selector or element that defines the containment context for the popup.
* @default undefined
*/
container?: string | HTMLElement;
/**
* Whether the popup should automatically flip when there isn't enough space.
* @default true
*/
flip: boolean;
/**
* Resolved Floating UI config for direct use with `computePosition()`.
* This is the escape hatch: reach for it only when you mount the element
* yourself and want to run the positioning loop manually instead of using
* {@link SuggestionProps.mount}.
*/
floatingUi: SuggestionFloatingUiConfig;
/**
* Mounts your floating element and takes over positioning — the recommended,
* default way to render a suggestion popup.
*
* Pass the element you rendered (e.g. from `ReactRenderer`/`VueRenderer`). The
* plugin appends it into the configured `container` (default `document.body`),
* keeps it anchored to the cursor, and repositions it on scroll, resize, and
* layout shifts — no manual listeners required. Returns an `unmount` function
* that tears everything down; call it in `onExit`.
*
* Escape hatch: mount the element yourself and skip this, then run your own
* positioning loop with {@link SuggestionProps.floatingUi} +
* {@link SuggestionProps.clientRect}.
*
* @example
* ```ts
* onStart: props => {
* component = new ReactRenderer(DropdownList, { props, editor: props.editor })
* unmount = props.mount(component.element)
* },
* onExit: () => unmount?.(),
* ```
*/
mount: SuggestionMount;
/**
* Whether the items are currently being loaded.
* `true` before the async `items()` call resolves.
* Useful for showing a loading spinner or skeleton.
* @default false
*/
loading: boolean;
}
/**
* The props passed to the suggestion's onKeyDown render function
*/
interface SuggestionKeyDownProps {

@@ -217,2 +397,13 @@ view: EditorView;

}
/** @internal Internal state shape for the suggestion plugin. */
interface SuggestionPluginState {
active: boolean;
range: Range;
query: null | string;
text: null | string;
composing: boolean;
decorationId?: string | null;
dismissedRange: Range | null;
}
declare const SuggestionPluginKey: PluginKey<any>;

@@ -223,3 +414,3 @@ /**

*/
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, shouldShow, shouldResetDismissed, }: SuggestionOptions<I, TSelected>): Plugin<any>;
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, minQueryLength, debounce, initialItems, placement, offset: offsetOption, container, flip, floatingUi, dismissOnOutsideClick, render, allow, findSuggestionMatch, shouldShow, shouldResetDismissed, }: SuggestionOptions<I, TSelected>): Plugin<SuggestionPluginState>;
/**

@@ -232,2 +423,2 @@ * Programmatically exit a suggestion plugin by dispatching a metadata-only

export { Suggestion, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionOptions, SuggestionPluginKey, type SuggestionProps, type Trigger, Suggestion as default, exitSuggestion, findSuggestionMatch };
export { Suggestion, type SuggestionFloatingUiConfig, type SuggestionFloatingUiOptions, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionMount, type SuggestionMountOptions, type SuggestionOptions, type SuggestionPlacement, SuggestionPluginKey, type SuggestionPositionData, type SuggestionProps, type Trigger, Suggestion as default, exitSuggestion, findSuggestionMatch };
// src/suggestion.ts
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";

@@ -56,3 +55,3 @@ // src/findSuggestionMatch.ts

// src/suggestion.ts
// src/helpers.ts
function hasInsertedWhitespace(transaction) {

@@ -71,27 +70,4 @@ if (!transaction.docChanged) {

}
var SuggestionPluginKey = new PluginKey("suggestion");
function Suggestion({
pluginKey = SuggestionPluginKey,
editor,
char = "@",
allowSpaces = false,
allowToIncludeChar = false,
allowedPrefixes = [" "],
startOfLine = false,
decorationTag = "span",
decorationClass = "suggestion",
decorationContent = "",
decorationEmptyClass = "is-empty",
command = () => null,
items = () => [],
render = () => ({}),
allow = () => true,
findSuggestionMatch: findSuggestionMatch2 = findSuggestionMatch,
shouldShow,
shouldResetDismissed
}) {
let props;
const renderer = render == null ? void 0 : render();
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar;
const getAnchorClientRect = () => {
function getAnchorClientRect(editor) {
return () => {
const pos = editor.state.selection.$anchor.pos;

@@ -106,271 +82,649 @@ const coords = editor.view.coordsAtPos(pos);

};
const clientRectFor = (view, decorationNode) => {
if (!decorationNode) {
return getAnchorClientRect;
}
return () => {
const state = pluginKey.getState(editor.state);
const decorationId = state == null ? void 0 : state.decorationId;
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`);
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
};
}
function clientRectFor(editor, view, decorationNode, pluginKey) {
if (!decorationNode) {
return getAnchorClientRect(editor);
}
return () => {
const state = pluginKey.getState(editor.state);
const decorationId = state == null ? void 0 : state.decorationId;
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`);
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
};
const shouldKeepDismissed = ({
}
function shouldKeepDismissed({
match,
dismissedRange,
state,
transaction,
editor,
shouldResetDismissed,
effectiveAllowSpaces
}) {
if (shouldResetDismissed == null ? void 0 : shouldResetDismissed({
editor,
state,
range: dismissedRange,
match,
dismissedRange,
state,
transaction
}) => {
if (shouldResetDismissed == null ? void 0 : shouldResetDismissed({
editor,
state,
range: dismissedRange,
match,
transaction,
allowSpaces: effectiveAllowSpaces
})) {
return false;
transaction,
allowSpaces: effectiveAllowSpaces
})) {
return false;
}
if (effectiveAllowSpaces) {
return match.range.from === dismissedRange.from;
}
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction);
}
function dispatchExit({
view,
pluginKeyRef
}) {
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true });
view.dispatch(tr);
}
// src/plugin/props.ts
import { Decoration, DecorationSet } from "@tiptap/pm/view";
function createSuggestionProps({
pluginKey,
decorationTag,
decorationClass,
decorationContent,
decorationEmptyClass,
renderer,
dispatchExit: dispatchExit2
}) {
return {
/**
* Call the keydown hook if suggestion is active.
*/
handleKeyDown(view, event) {
var _a, _b;
const state = pluginKey.getState(view.state);
if (!state.active) {
return false;
}
if (event.key === "Escape" || event.key === "Esc") {
(_a = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _a.call(renderer, { view, event, range: state.range });
dispatchExit2(view);
return true;
}
const handled = ((_b = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _b.call(renderer, { view, event, range: state.range })) || false;
return handled;
},
/**
* Setup decorator on the currently active suggestion.
*/
decorations(state) {
const pluginState = pluginKey.getState(state);
const { active, range, decorationId, query } = pluginState;
if (!active) {
return null;
}
const isEmpty = !(query == null ? void 0 : query.length);
const classNames = [decorationClass];
if (isEmpty) {
classNames.push(decorationEmptyClass);
}
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, {
nodeName: decorationTag,
class: classNames.join(" "),
"data-decoration-id": decorationId || void 0,
"data-decoration-content": decorationContent
})
]);
}
if (effectiveAllowSpaces) {
return match.range.from === dismissedRange.from;
}
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction);
};
function dispatchExit(view, pluginKeyRef) {
var _a;
try {
const state = pluginKey.getState(view.state);
const decorationNode = (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null;
const exitProps = {
// @ts-ignore editor is available in closure
editor,
range: (state == null ? void 0 : state.range) || { from: 0, to: 0 },
query: (state == null ? void 0 : state.query) || null,
text: (state == null ? void 0 : state.text) || null,
items: [],
command: (commandProps) => {
return command({
editor,
range: (state == null ? void 0 : state.range) || { from: 0, to: 0 },
props: commandProps
});
},
decorationNode,
clientRect: clientRectFor(view, decorationNode)
};
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, exitProps);
} catch {
}
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true });
view.dispatch(tr);
}
const plugin = new Plugin({
key: pluginKey,
view() {
}
// src/plugin/state.ts
function createSuggestionState({
editor,
char,
effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
findSuggestionMatch: findSuggestionMatch2,
allow,
shouldShow,
shouldKeepDismissed: shouldKeepDismissed2,
pluginKey
}) {
return {
/**
* Initialize the plugin's internal state.
*/
init() {
return {
update: async (view, prevState) => {
var _a, _b, _c, _d, _e, _f, _g;
const prev = (_a = this.key) == null ? void 0 : _a.getState(prevState);
const next = (_b = this.key) == null ? void 0 : _b.getState(view.state);
const moved = prev.active && next.active && prev.range.from !== next.range.from;
const started = !prev.active && next.active;
const stopped = prev.active && !next.active;
const changed = !started && !stopped && prev.query !== next.query;
const handleStart = started || moved && changed;
const handleChange = changed || moved;
const handleExit = stopped || moved && changed;
if (!handleStart && !handleChange && !handleExit) {
return;
}
const state = handleExit && !handleStart ? prev : next;
const decorationNode = view.dom.querySelector(
`[data-decoration-id="${state.decorationId}"]`
);
props = {
editor,
range: state.range,
query: state.query,
text: state.text,
items: [],
command: (commandProps) => {
return command({
editor,
range: state.range,
props: commandProps
});
},
decorationNode,
clientRect: clientRectFor(view, decorationNode)
};
if (handleStart) {
(_c = renderer == null ? void 0 : renderer.onBeforeStart) == null ? void 0 : _c.call(renderer, props);
}
if (handleChange) {
(_d = renderer == null ? void 0 : renderer.onBeforeUpdate) == null ? void 0 : _d.call(renderer, props);
}
if (handleChange || handleStart) {
props.items = await items({
editor,
query: state.query
});
}
if (handleExit) {
(_e = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _e.call(renderer, props);
}
if (handleChange) {
(_f = renderer == null ? void 0 : renderer.onUpdate) == null ? void 0 : _f.call(renderer, props);
}
if (handleStart) {
(_g = renderer == null ? void 0 : renderer.onStart) == null ? void 0 : _g.call(renderer, props);
}
},
destroy: () => {
var _a;
if (!props) {
return;
}
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, props);
}
active: false,
range: { from: 0, to: 0 },
query: null,
text: null,
composing: false,
dismissedRange: null
};
},
state: {
// Initialize the plugin's internal state.
init() {
const state = {
active: false,
range: {
from: 0,
to: 0
},
query: null,
text: null,
composing: false,
dismissedRange: null
/**
* Apply changes to the plugin state from a view transaction.
*/
apply(transaction, prev, _oldState, state) {
const { isEditable } = editor;
const { composing } = editor.view;
const { selection } = transaction;
const { empty, from } = selection;
const next = { ...prev };
const meta = transaction.getMeta(pluginKey);
if (meta && meta.exit) {
next.active = false;
next.decorationId = null;
next.range = { from: 0, to: 0 };
next.query = null;
next.text = null;
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange;
return next;
}
next.composing = composing;
if (transaction.docChanged && next.dismissedRange !== null) {
next.dismissedRange = {
from: transaction.mapping.map(next.dismissedRange.from),
to: transaction.mapping.map(next.dismissedRange.to)
};
return state;
},
// Apply changes to the plugin state from a view transaction.
apply(transaction, prev, _oldState, state) {
const { isEditable } = editor;
const { composing } = editor.view;
const { selection } = transaction;
const { empty, from } = selection;
const next = { ...prev };
const meta = transaction.getMeta(pluginKey);
if (meta && meta.exit) {
}
if (isEditable && (empty || editor.view.composing)) {
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
next.active = false;
next.decorationId = null;
next.range = { from: 0, to: 0 };
next.query = null;
next.text = null;
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange;
return next;
}
next.composing = composing;
if (transaction.docChanged && next.dismissedRange !== null) {
next.dismissedRange = {
from: transaction.mapping.map(next.dismissedRange.from),
to: transaction.mapping.map(next.dismissedRange.to)
};
}
if (isEditable && (empty || editor.view.composing)) {
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
next.active = false;
}
const match = findSuggestionMatch2({
char,
allowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
$position: selection.$from
});
const decorationId = `id_${Math.floor(Math.random() * 4294967295)}`;
if (match && allow({
editor,
const match = findSuggestionMatch2({
char,
allowSpaces: effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
$position: selection.$from
});
const decorationId = `id_${Math.floor(Math.random() * 4294967295)}`;
if (match && allow({
editor,
state,
range: match.range,
isActive: prev.active
}) && (!shouldShow || shouldShow({
editor,
range: match.range,
query: match.query,
text: match.text,
transaction
}))) {
if (next.dismissedRange !== null && !shouldKeepDismissed2({
match,
dismissedRange: next.dismissedRange,
state,
range: match.range,
isActive: prev.active
}) && (!shouldShow || shouldShow({
editor,
range: match.range,
query: match.query,
text: match.text,
transaction
}))) {
if (next.dismissedRange !== null && !shouldKeepDismissed({
match,
dismissedRange: next.dismissedRange,
state,
transaction
})) {
next.dismissedRange = null;
}
if (next.dismissedRange === null) {
next.active = true;
next.decorationId = prev.decorationId ? prev.decorationId : decorationId;
next.range = match.range;
next.query = match.query;
next.text = match.text;
} else {
next.active = false;
}
})) {
next.dismissedRange = null;
}
if (next.dismissedRange === null) {
next.active = true;
next.decorationId = prev.decorationId || decorationId;
next.range = match.range;
next.query = match.query;
next.text = match.text;
} else {
if (!match) {
next.dismissedRange = null;
}
next.active = false;
}
} else {
if (!match) {
next.dismissedRange = null;
}
next.active = false;
}
if (!next.active) {
next.decorationId = null;
next.range = { from: 0, to: 0 };
next.query = null;
next.text = null;
}
return next;
} else {
next.active = false;
}
},
props: {
// Call the keydown hook if suggestion is active.
handleKeyDown(view, event) {
var _a, _b;
const { active, range } = plugin.getState(view.state);
if (!active) {
return false;
if (!next.active) {
next.decorationId = null;
next.range = { from: 0, to: 0 };
next.query = null;
next.text = null;
}
return next;
}
};
}
// src/plugin/async.ts
function createSuggestionAsyncRequestManager({
editor,
items
}) {
let abortController = null;
let debounceTimer = null;
let debounceResolve = null;
const clearDebounceTimer = () => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
debounceResolve == null ? void 0 : debounceResolve();
debounceResolve = null;
};
const waitForDebounce = (delay) => {
return new Promise((resolve) => {
debounceResolve = resolve;
debounceTimer = setTimeout(() => {
debounceTimer = null;
const pendingResolve = debounceResolve;
debounceResolve = null;
pendingResolve == null ? void 0 : pendingResolve();
}, delay);
});
};
const abort = () => {
abortController == null ? void 0 : abortController.abort();
clearDebounceTimer();
abortController = null;
};
const fetch = async (query, debounce) => {
abort();
abortController = new AbortController();
const controller = abortController;
if (debounce > 0) {
await waitForDebounce(debounce);
}
if (abortController !== controller || controller.signal.aborted) {
return { status: "aborted" };
}
try {
const result = await items({
editor,
query,
signal: controller.signal
});
if (abortController !== controller || controller.signal.aborted) {
return { status: "aborted" };
}
return { status: "resolved", items: result };
} catch {
if (abortController !== controller || controller.signal.aborted) {
return { status: "aborted" };
}
return { status: "error" };
}
};
return {
abort,
fetch
};
}
// src/plugin/floating-ui.ts
import {
autoUpdate,
computePosition,
flip as floatingUiFlip,
offset as floatingUiOffset
} from "@floating-ui/dom";
function createSuggestionFloatingUiConfig({
placement,
offset,
flip,
floatingUi
}) {
var _a, _b, _c, _d;
const middleware = [
floatingUiOffset({
mainAxis: (_a = offset.mainAxis) != null ? _a : 4,
crossAxis: (_b = offset.crossAxis) != null ? _b : 0
})
];
if (flip) {
middleware.push(floatingUiFlip());
}
if ((_c = floatingUi == null ? void 0 : floatingUi.middleware) == null ? void 0 : _c.length) {
middleware.push(...floatingUi.middleware);
}
return {
placement,
strategy: (_d = floatingUi == null ? void 0 : floatingUi.strategy) != null ? _d : "absolute",
middleware
};
}
function resolveContainer(container) {
if (container instanceof HTMLElement) {
return container;
}
if (typeof container === "string") {
try {
const found = document.querySelector(container);
if (found) {
return found;
}
} catch {
return document.body;
}
}
return document.body;
}
function createMount({
getReferenceRect,
contextElement,
config,
container,
dismissOnOutsideClick,
dismiss
}) {
return (element, options = {}) => {
const reference = {
getBoundingClientRect: () => {
var _a;
return (_a = getReferenceRect()) != null ? _a : new DOMRect();
},
contextElement
};
let positioned = false;
const mountedByUs = !element.isConnected;
if (mountedByUs) {
resolveContainer(container).appendChild(element);
}
if (!options.onPosition) {
element.style.visibility = "hidden";
element.style.width = "max-content";
}
const update = () => {
computePosition(reference, element, {
placement: config.placement,
strategy: config.strategy,
middleware: config.middleware
}).then(({ x, y, placement, strategy }) => {
if (options.onPosition) {
options.onPosition({ x, y, placement, strategy });
return;
}
if (event.key === "Escape" || event.key === "Esc") {
const state = plugin.getState(view.state);
(_a = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _a.call(renderer, { view, event, range: state.range });
dispatchExit(view, pluginKey);
return true;
Object.assign(element.style, {
position: strategy,
left: `${x}px`,
top: `${y}px`
});
if (!positioned) {
positioned = true;
element.style.visibility = "";
}
const handled = ((_b = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _b.call(renderer, { view, event, range })) || false;
return handled;
},
// Setup decorator on the currently active suggestion.
decorations(state) {
const { active, range, decorationId, query } = plugin.getState(state);
if (!active) {
return null;
});
};
const cleanupAutoUpdate = autoUpdate(reference, element, update, options.autoUpdate);
let onOutsidePointerDown;
if (dismissOnOutsideClick) {
onOutsidePointerDown = (event) => {
const target = event.target;
if (!(target instanceof Node) || element.contains(target) || contextElement.contains(target)) {
return;
}
const isEmpty = !(query == null ? void 0 : query.length);
const classNames = [decorationClass];
if (isEmpty) {
classNames.push(decorationEmptyClass);
dismiss();
};
document.addEventListener("pointerdown", onOutsidePointerDown, true);
}
return () => {
cleanupAutoUpdate();
if (onOutsidePointerDown) {
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
}
if (mountedByUs) {
element.remove();
}
};
};
}
// src/plugin/view.ts
function createSuggestionView({
editor,
pluginKey,
items,
renderer,
minQueryLength,
debounce,
initialItems,
placement,
offset: offsetOption,
container,
flip,
floatingUi,
dismissOnOutsideClick,
command,
clientRectFor: clientRectFor2,
dispatchExit: dispatchExit2
}) {
let props;
const asyncRequest = createSuggestionAsyncRequestManager({
editor,
items
});
const floatingUiConfig = createSuggestionFloatingUiConfig({
placement,
offset: offsetOption,
flip,
floatingUi
});
function dispatchStateUpdate(state, dispatchProps) {
var _a, _b, _c;
switch (state) {
case "started":
(_a = renderer == null ? void 0 : renderer.onStart) == null ? void 0 : _a.call(renderer, dispatchProps);
break;
case "updated":
(_b = renderer == null ? void 0 : renderer.onUpdate) == null ? void 0 : _b.call(renderer, dispatchProps);
break;
case "stopped":
(_c = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _c.call(renderer, dispatchProps);
break;
default:
break;
}
}
return {
update: async (view, prevState) => {
var _a, _b, _c, _d;
const prev = pluginKey.getState(prevState);
const next = pluginKey.getState(view.state);
if (!prev || !next) {
return;
}
let currentState = null;
const queryChanged = prev.query !== next.query;
const textChanged = prev.text !== next.text;
const rangeChanged = prev.range.from !== next.range.from || prev.range.to !== next.range.to;
const effectiveQueryChanged = queryChanged || textChanged || rangeChanged;
if (!prev.active && next.active) {
currentState = "started";
} else if (prev.active && !next.active) {
currentState = "stopped";
} else if (next.active && effectiveQueryChanged) {
currentState = "updated";
} else {
return;
}
const state = currentState === "stopped" ? prev : next;
const decorationNode = view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`);
const clientRect = clientRectFor2(view, decorationNode);
const exceedsMinQueryLength = minQueryLength === 0 || (state.query ? state.query.length >= minQueryLength : false);
const willFetch = (currentState === "started" || currentState === "updated") && exceedsMinQueryLength;
props = {
editor,
range: state.range,
query: state.query || "",
text: state.text || "",
items: initialItems != null ? initialItems : [],
command: (commandProps) => {
return command({
editor,
range: state.range,
props: commandProps
});
},
decorationNode,
clientRect,
loading: willFetch,
placement,
offset: { mainAxis: (_a = offsetOption.mainAxis) != null ? _a : 4, crossAxis: (_b = offsetOption.crossAxis) != null ? _b : 0 },
container,
flip,
floatingUi: floatingUiConfig,
mount: createMount({
getReferenceRect: clientRect,
contextElement: view.dom,
config: floatingUiConfig,
container,
dismissOnOutsideClick,
dismiss: () => dispatchExit2(editor.view)
})
};
if (currentState === "started") {
(_c = renderer == null ? void 0 : renderer.onBeforeStart) == null ? void 0 : _c.call(renderer, props);
}
if (currentState === "updated") {
(_d = renderer == null ? void 0 : renderer.onBeforeUpdate) == null ? void 0 : _d.call(renderer, props);
}
if (currentState === "started") {
dispatchStateUpdate(currentState, props);
}
if (currentState === "started" || currentState === "updated") {
if (!willFetch) {
asyncRequest.abort();
props = { ...props, items: initialItems != null ? initialItems : [], loading: false };
} else {
props = { ...props, items: initialItems != null ? initialItems : [], loading: true };
currentState = "updated";
dispatchStateUpdate(currentState, props);
const result = await asyncRequest.fetch(state.query || "", debounce);
if (result.status === "aborted") {
return;
}
const currentPluginState = pluginKey.getState(view.state);
if (!(currentPluginState == null ? void 0 : currentPluginState.active)) {
asyncRequest.abort();
return;
}
props = result.status === "resolved" ? {
...props,
items: result.items,
loading: false
} : {
...props,
loading: false
};
}
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, {
nodeName: decorationTag,
class: classNames.join(" "),
"data-decoration-id": decorationId,
"data-decoration-content": decorationContent
})
]);
}
if (currentState === "stopped") {
asyncRequest.abort();
dispatchStateUpdate(currentState, props);
props = void 0;
return;
}
if (currentState === "updated") {
dispatchStateUpdate(currentState, props);
}
},
destroy: () => {
var _a;
asyncRequest.abort();
if (!props) {
return;
}
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, props);
}
};
}
// src/suggestion.ts
var SuggestionPluginKey = new PluginKey("suggestion");
function Suggestion({
pluginKey = SuggestionPluginKey,
editor,
char = "@",
allowSpaces = false,
allowToIncludeChar = false,
allowedPrefixes = [" "],
startOfLine = false,
decorationTag = "span",
decorationClass = "suggestion",
decorationContent = "",
decorationEmptyClass = "is-empty",
command = () => null,
items = () => [],
minQueryLength = 0,
debounce = 0,
initialItems,
placement = "bottom-start",
offset: offsetOption = {},
container,
flip = true,
floatingUi,
dismissOnOutsideClick = true,
render = () => ({}),
allow = () => true,
findSuggestionMatch: findSuggestionMatch2 = findSuggestionMatch,
shouldShow,
shouldResetDismissed
}) {
const renderer = render == null ? void 0 : render();
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar;
const clientRectFor2 = (view, decorationNode) => clientRectFor(editor, view, decorationNode, pluginKey);
function shouldKeepDismissed2(props) {
return shouldKeepDismissed({
...props,
editor,
shouldResetDismissed,
effectiveAllowSpaces
});
}
const dispatchExit2 = (view) => dispatchExit({
view,
pluginKeyRef: pluginKey
});
return plugin;
return new Plugin({
key: pluginKey,
view: () => createSuggestionView({
editor,
pluginKey,
items,
renderer,
minQueryLength,
debounce,
initialItems,
placement,
offset: offsetOption,
container,
flip,
floatingUi,
dismissOnOutsideClick,
command,
clientRectFor: clientRectFor2,
dispatchExit: dispatchExit2
}),
state: createSuggestionState({
editor,
char,
effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
findSuggestionMatch: findSuggestionMatch2,
allow,
shouldShow,
shouldKeepDismissed: shouldKeepDismissed2,
pluginKey
}),
props: createSuggestionProps({
pluginKey,
decorationTag,
decorationClass,
decorationContent,
decorationEmptyClass,
renderer,
dispatchExit: dispatchExit2
})
});
}

@@ -377,0 +731,0 @@ function exitSuggestion(view, pluginKeyRef = SuggestionPluginKey) {

@@ -1,1 +0,1 @@

{"version":3,"sources":["../src/suggestion.ts","../src/findSuggestionMatch.ts","../src/index.ts"],"sourcesContent":["import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nimport type { SuggestionMatch } from './findSuggestionMatch.js'\nimport { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'\n\n/**\n * Returns true if the transaction inserted any whitespace or newline character.\n * Used to determine when a dismissed suggestion should become active again.\n */\nfunction hasInsertedWhitespace(transaction: Transaction): boolean {\n if (!transaction.docChanged) {\n return false\n }\n return transaction.steps.some(step => {\n const slice = (step as any).slice\n if (!slice?.content) {\n return false\n }\n // textBetween with '\\n' as block separator catches both inline spaces and newlines\n const inserted = slice.content.textBetween(0, slice.content.size, '\\n')\n return /\\s/.test(inserted)\n })\n}\n\nexport interface SuggestionOptions<I = any, TSelected = any> {\n /**\n * The plugin key for the suggestion plugin.\n * @default 'suggestion'\n * @example 'mention'\n */\n pluginKey?: PluginKey\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * This is useful to prevent suggestions from opening for remote users in collaborative environments.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.query The current suggestion query.\n * @param props.text The current suggestion text.\n * @param props.transaction The current transaction.\n * @returns {boolean}\n * @example ({ transaction }) => isChangeOrigin(transaction)\n */\n shouldShow?: (props: {\n editor: Editor\n range: Range\n query: string\n text: string\n transaction: Transaction\n }) => boolean\n\n /**\n * Controls when a dismissed suggestion becomes active again.\n * Return `true` to clear the dismissed context for the current transaction.\n */\n shouldResetDismissed?: (props: {\n editor: Editor\n state: EditorState\n range: Range\n match: Exclude<SuggestionMatch, null>\n transaction: Transaction\n allowSpaces: boolean\n }) => boolean\n\n /**\n * The editor instance.\n * @default null\n */\n editor: Editor\n\n /**\n * The character that triggers the suggestion.\n * @default '@'\n * @example '#'\n */\n char?: string\n\n /**\n * Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.\n * @default false\n * @example true\n */\n allowSpaces?: boolean\n\n /**\n * Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.\n * @default false\n */\n allowToIncludeChar?: boolean\n\n /**\n * Allow prefixes in the suggestion query.\n * @default [' ']\n * @example [' ', '@']\n */\n allowedPrefixes?: string[] | null\n\n /**\n * Only match suggestions at the start of the line.\n * @default false\n * @example true\n */\n startOfLine?: boolean\n\n /**\n * The tag name of the decoration node.\n * @default 'span'\n * @example 'div'\n */\n decorationTag?: string\n\n /**\n * The class name of the decoration node.\n * @default 'suggestion'\n * @example 'mention'\n */\n decorationClass?: string\n\n /**\n * Creates a decoration with the provided content.\n * @param decorationContent - The content to display in the decoration\n * @default \"\" - Creates an empty decoration if no content provided\n */\n decorationContent?: string\n\n /**\n * The class name of the decoration node when it is empty.\n * @default 'is-empty'\n * @example 'is-empty'\n */\n decorationEmptyClass?: string\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.props The props of the selected suggestion.\n * @returns void\n * @example ({ editor, range, props }) => { props.command(props.props) }\n */\n command?: (props: { editor: Editor; range: Range; props: TSelected }) => void\n\n /**\n * A function that returns the suggestion items in form of an array.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.query The current suggestion query.\n * @returns An array of suggestion items.\n * @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]\n */\n items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>\n\n /**\n * The render function for the suggestion.\n * @returns An object with render functions.\n */\n render?: () => {\n onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void\n onStart?: (props: SuggestionProps<I, TSelected>) => void\n onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onExit?: (props: SuggestionProps<I, TSelected>) => void\n onKeyDown?: (props: SuggestionKeyDownProps) => boolean\n }\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * @param props The props object.\n * @returns {boolean}\n */\n allow?: (props: {\n editor: Editor\n state: EditorState\n range: Range\n isActive?: boolean\n }) => boolean\n findSuggestionMatch?: typeof defaultFindSuggestionMatch\n}\n\nexport interface SuggestionProps<I = any, TSelected = any> {\n /**\n * The editor instance.\n */\n editor: Editor\n\n /**\n * The range of the suggestion.\n */\n range: Range\n\n /**\n * The current suggestion query.\n */\n query: string\n\n /**\n * The current suggestion text.\n */\n text: string\n\n /**\n * The suggestion items array.\n */\n items: I[]\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @returns void\n */\n command: (props: TSelected) => void\n\n /**\n * The decoration node HTML element\n * @default null\n */\n decorationNode: Element | null\n\n /**\n * The function that returns the client rect\n * @default null\n * @example () => new DOMRect(0, 0, 0, 0)\n */\n clientRect?: (() => DOMRect | null) | null\n}\n\nexport interface SuggestionKeyDownProps {\n view: EditorView\n event: KeyboardEvent\n range: Range\n}\n\nexport const SuggestionPluginKey = new PluginKey('suggestion')\n\n/**\n * This utility allows you to create suggestions.\n * @see https://tiptap.dev/api/utilities/suggestion\n */\nexport function Suggestion<I = any, TSelected = any>({\n pluginKey = SuggestionPluginKey,\n editor,\n char = '@',\n allowSpaces = false,\n allowToIncludeChar = false,\n allowedPrefixes = [' '],\n startOfLine = false,\n decorationTag = 'span',\n decorationClass = 'suggestion',\n decorationContent = '',\n decorationEmptyClass = 'is-empty',\n command = () => null,\n items = () => [],\n render = () => ({}),\n allow = () => true,\n findSuggestionMatch = defaultFindSuggestionMatch,\n shouldShow,\n shouldResetDismissed,\n}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\n const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar\n\n // Gets the DOM rectangle corresponding to the current editor cursor anchor position\n // Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object\n const getAnchorClientRect = () => {\n const pos = editor.state.selection.$anchor.pos\n const coords = editor.view.coordsAtPos(pos)\n const { top, right, bottom, left } = coords\n\n try {\n return new DOMRect(left, top, right - left, bottom - top)\n } catch {\n return null\n }\n }\n\n // Helper to create a clientRect callback for a given decoration node.\n // Returns null when no decoration node is present. Uses the pluginKey's\n // state to resolve the current decoration node on demand, avoiding a\n // duplicated implementation in multiple places.\n const clientRectFor = (view: EditorView, decorationNode: Element | null) => {\n if (!decorationNode) {\n return getAnchorClientRect\n }\n\n return () => {\n const state = pluginKey.getState(editor.state)\n const decorationId = state?.decorationId\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n }\n\n const shouldKeepDismissed = ({\n match,\n dismissedRange,\n state,\n transaction,\n }: {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n }) => {\n if (\n shouldResetDismissed?.({\n editor,\n state,\n range: dismissedRange,\n match,\n transaction,\n allowSpaces: effectiveAllowSpaces,\n })\n ) {\n return false\n }\n\n if (effectiveAllowSpaces) {\n return match.range.from === dismissedRange.from\n }\n\n return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)\n }\n\n // small helper used internally by the view to dispatch an exit\n function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {\n try {\n const state = pluginKey.getState(view.state)\n const decorationNode = state?.decorationId\n ? view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n : null\n\n const exitProps: SuggestionProps = {\n // @ts-ignore editor is available in closure\n editor,\n range: state?.range || { from: 0, to: 0 },\n query: state?.query || null,\n text: state?.text || null,\n items: [],\n command: commandProps => {\n return command({\n editor,\n range: state?.range || { from: 0, to: 0 },\n props: commandProps as any,\n })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n renderer?.onExit?.(exitProps)\n } catch {\n // ignore errors from consumer renderers\n }\n\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n // Dispatch a metadata-only transaction to signal the plugin to exit\n view.dispatch(tr)\n }\n\n const plugin: Plugin<any> = new Plugin({\n key: pluginKey,\n\n view() {\n return {\n update: async (view, prevState) => {\n const prev = this.key?.getState(prevState)\n const next = this.key?.getState(view.state)\n\n // See how the state changed\n const moved = prev.active && next.active && prev.range.from !== next.range.from\n const started = !prev.active && next.active\n const stopped = prev.active && !next.active\n const changed = !started && !stopped && prev.query !== next.query\n\n const handleStart = started || (moved && changed)\n const handleChange = changed || moved\n const handleExit = stopped || (moved && changed)\n\n // Cancel when suggestion isn't active\n if (!handleStart && !handleChange && !handleExit) {\n return\n }\n\n const state = handleExit && !handleStart ? prev : next\n const decorationNode = view.dom.querySelector(\n `[data-decoration-id=\"${state.decorationId}\"]`,\n )\n\n props = {\n editor,\n range: state.range,\n query: state.query,\n text: state.text,\n items: [],\n command: commandProps => {\n return command({\n editor,\n range: state.range,\n props: commandProps,\n })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n if (handleStart) {\n renderer?.onBeforeStart?.(props)\n }\n\n if (handleChange) {\n renderer?.onBeforeUpdate?.(props)\n }\n\n if (handleChange || handleStart) {\n props.items = await items({\n editor,\n query: state.query,\n })\n }\n\n if (handleExit) {\n renderer?.onExit?.(props)\n }\n\n if (handleChange) {\n renderer?.onUpdate?.(props)\n }\n\n if (handleStart) {\n renderer?.onStart?.(props)\n }\n },\n\n destroy: () => {\n if (!props) {\n return\n }\n\n renderer?.onExit?.(props)\n },\n }\n },\n\n state: {\n // Initialize the plugin's internal state.\n init() {\n const state: {\n active: boolean\n range: Range\n query: null | string\n text: null | string\n composing: boolean\n decorationId?: string | null\n dismissedRange: Range | null\n } = {\n active: false,\n range: {\n from: 0,\n to: 0,\n },\n query: null,\n text: null,\n composing: false,\n dismissedRange: null,\n }\n\n return state\n },\n\n // Apply changes to the plugin state from a view transaction.\n apply(transaction, prev, _oldState, state) {\n const { isEditable } = editor\n const { composing } = editor.view\n const { selection } = transaction\n const { empty, from } = selection\n const next = { ...prev }\n\n // If a transaction carries the exit meta for this plugin, immediately\n // deactivate the suggestion. This allows metadata-only transactions\n // (dispatched by escape or programmatic exit) to deterministically\n // clear decorations without changing the document.\n const meta = transaction.getMeta(pluginKey)\n if (meta && meta.exit) {\n next.active = false\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange\n\n return next\n }\n\n next.composing = composing\n\n if (transaction.docChanged && next.dismissedRange !== null) {\n next.dismissedRange = {\n from: transaction.mapping.map(next.dismissedRange.from),\n to: transaction.mapping.map(next.dismissedRange.to),\n }\n }\n\n // We can only be suggesting if the view is editable, and:\n // * there is no selection, or\n // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)\n if (isEditable && (empty || editor.view.composing)) {\n // Reset active state if we just left the previous suggestion range\n if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {\n next.active = false\n }\n\n // Try to match against where our cursor currently is\n const match = findSuggestionMatch({\n char,\n allowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position: selection.$from,\n })\n const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`\n\n // If we found a match, update the current state to show it\n if (\n match &&\n allow({\n editor,\n state,\n range: match.range,\n isActive: prev.active,\n }) &&\n (!shouldShow ||\n shouldShow({\n editor,\n range: match.range,\n query: match.query,\n text: match.text,\n transaction,\n }))\n ) {\n if (\n next.dismissedRange !== null &&\n !shouldKeepDismissed({\n match,\n dismissedRange: next.dismissedRange,\n state,\n transaction,\n })\n ) {\n next.dismissedRange = null\n }\n\n if (next.dismissedRange === null) {\n next.active = true\n next.decorationId = prev.decorationId ? prev.decorationId : decorationId\n next.range = match.range\n next.query = match.query\n next.text = match.text\n } else {\n next.active = false\n }\n } else {\n if (!match) {\n next.dismissedRange = null\n }\n next.active = false\n }\n } else {\n next.active = false\n }\n\n // Make sure to empty the range if suggestion is inactive\n if (!next.active) {\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n }\n\n return next\n },\n },\n\n props: {\n // Call the keydown hook if suggestion is active.\n handleKeyDown(view, event) {\n const { active, range } = plugin.getState(view.state)\n\n if (!active) {\n return false\n }\n\n // If Escape is pressed, call onExit and dispatch a metadata-only\n // transaction to unset the suggestion state. This provides a safe\n // and deterministic way to exit the suggestion without altering the\n // document (avoids transaction mapping/mismatch issues).\n if (event.key === 'Escape' || event.key === 'Esc') {\n const state = plugin.getState(view.state)\n\n // Allow the consumer to react to Escape, but always clear the\n // suggestion state afterward so the decoration is removed too.\n renderer?.onKeyDown?.({ view, event, range: state.range })\n\n // dispatch metadata-only transaction to unset the plugin state\n dispatchExit(view, pluginKey)\n\n return true\n }\n\n const handled = renderer?.onKeyDown?.({ view, event, range }) || false\n return handled\n },\n\n // Setup decorator on the currently active suggestion.\n decorations(state) {\n const { active, range, decorationId, query } = plugin.getState(state)\n\n if (!active) {\n return null\n }\n\n const isEmpty = !query?.length\n const classNames = [decorationClass]\n\n if (isEmpty) {\n classNames.push(decorationEmptyClass)\n }\n\n return DecorationSet.create(state.doc, [\n Decoration.inline(range.from, range.to, {\n nodeName: decorationTag,\n class: classNames.join(' '),\n 'data-decoration-id': decorationId,\n 'data-decoration-content': decorationContent,\n }),\n ])\n },\n },\n })\n\n return plugin\n}\n\n/**\n * Programmatically exit a suggestion plugin by dispatching a metadata-only\n * transaction. This is the safe, recommended API to remove suggestion\n * decorations without touching the document or causing mapping errors.\n */\nexport function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { Range } from '@tiptap/core'\nimport { escapeForRegEx } from '@tiptap/core'\nimport type { ResolvedPos } from '@tiptap/pm/model'\n\nexport interface Trigger {\n char: string\n allowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n $position: ResolvedPos\n}\n\nexport type SuggestionMatch = {\n range: Range\n query: string\n text: string\n} | null\n\nexport function findSuggestionMatch(config: Trigger): SuggestionMatch {\n const {\n char,\n allowSpaces: allowSpacesOption,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position,\n } = config\n\n const allowSpaces = allowSpacesOption && !allowToIncludeChar\n\n const escapedChar = escapeForRegEx(char)\n const suffix = new RegExp(`\\\\s${escapedChar}$`)\n const prefix = startOfLine ? '^' : ''\n const finalEscapedChar = allowToIncludeChar ? '' : escapedChar\n const regexp = allowSpaces\n ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, 'gm')\n : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, 'gm')\n\n const text = $position.nodeBefore?.isText && $position.nodeBefore.text\n\n if (!text) {\n return null\n }\n\n const textFrom = $position.pos - text.length\n const match = Array.from(text.matchAll(regexp)).pop()\n\n if (!match || match.input === undefined || match.index === undefined) {\n return null\n }\n\n // JavaScript doesn't have lookbehinds. This hacks a check that first character\n // is a space or the start of the line\n const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)\n const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\\0]?$`).test(matchPrefix)\n\n if (allowedPrefixes !== null && !matchPrefixIsAllowed) {\n return null\n }\n\n // The absolute position of the match in the document\n const from = textFrom + match.index\n let to = from + match[0].length\n\n // Edge case handling; if spaces are allowed and we're directly in between\n // two triggers\n if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n match[0] += ' '\n to += 1\n }\n\n // If the $position is located within the matched substring, return that range\n if (from < $position.pos && to >= $position.pos) {\n return {\n range: {\n from,\n to,\n },\n query: match[0].slice(char.length),\n text: match[0],\n }\n }\n\n return null\n}\n","import { exitSuggestion, Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport { exitSuggestion }\n\nexport default Suggestion\n"],"mappings":"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","names":["findSuggestionMatch"]}
{"version":3,"sources":["../src/suggestion.ts","../src/findSuggestionMatch.ts","../src/helpers.ts","../src/plugin/props.ts","../src/plugin/state.ts","../src/plugin/async.ts","../src/plugin/floating-ui.ts","../src/plugin/view.ts","../src/index.ts"],"sourcesContent":["import type { Range } from '@tiptap/core'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport type { SuggestionMatch } from './findSuggestionMatch.js'\nimport { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'\nimport {\n clientRectFor as clientRectForHelper,\n dispatchExit as dispatchExitHelper,\n shouldKeepDismissed as shouldKeepDismissedHelper,\n} from './helpers.js'\nimport { createSuggestionProps } from './plugin/props.js'\nimport { createSuggestionState } from './plugin/state.js'\nimport { createSuggestionView } from './plugin/view.js'\nimport type { SuggestionOptions } from './types.js'\n\nexport type {\n SuggestionFloatingUiConfig,\n SuggestionFloatingUiOptions,\n SuggestionKeyDownProps,\n SuggestionMount,\n SuggestionMountOptions,\n SuggestionOptions,\n SuggestionPlacement,\n SuggestionPositionData,\n SuggestionProps,\n} from './types.js'\n\nexport const SuggestionPluginKey = new PluginKey('suggestion')\n\ntype ShouldKeepDismissedProps = {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n}\n\n/**\n * This utility allows you to create suggestions.\n * @see https://tiptap.dev/api/utilities/suggestion\n */\nexport function Suggestion<I = any, TSelected = any>({\n pluginKey = SuggestionPluginKey,\n editor,\n char = '@',\n allowSpaces = false,\n allowToIncludeChar = false,\n allowedPrefixes = [' '],\n startOfLine = false,\n decorationTag = 'span',\n decorationClass = 'suggestion',\n decorationContent = '',\n decorationEmptyClass = 'is-empty',\n command = () => null,\n items = () => [],\n minQueryLength = 0,\n debounce = 0,\n initialItems,\n placement = 'bottom-start',\n offset: offsetOption = {},\n container,\n flip = true,\n floatingUi,\n dismissOnOutsideClick = true,\n render = () => ({}),\n allow = () => true,\n findSuggestionMatch = defaultFindSuggestionMatch,\n shouldShow,\n shouldResetDismissed,\n}: SuggestionOptions<I, TSelected>) {\n const renderer = render?.()\n const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar\n\n const clientRectFor = (view: EditorView, decorationNode: Element | null) =>\n clientRectForHelper(editor, view, decorationNode, pluginKey)\n\n // helper to check if the dismissed suggestion should stay dismissed, with access to editor and options\n function shouldKeepDismissed(props: ShouldKeepDismissedProps) {\n return shouldKeepDismissedHelper({\n ...props,\n editor,\n shouldResetDismissed,\n effectiveAllowSpaces,\n })\n }\n\n const dispatchExit = (view: EditorView) =>\n dispatchExitHelper({\n view,\n pluginKeyRef: pluginKey,\n })\n\n return new Plugin({\n key: pluginKey,\n\n view: () =>\n createSuggestionView({\n editor,\n pluginKey,\n items,\n renderer,\n minQueryLength,\n debounce,\n initialItems,\n placement,\n offset: offsetOption,\n container,\n flip,\n floatingUi,\n dismissOnOutsideClick,\n command,\n clientRectFor,\n dispatchExit,\n }),\n\n state: createSuggestionState({\n editor,\n char,\n effectiveAllowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n findSuggestionMatch,\n allow,\n shouldShow,\n shouldKeepDismissed,\n pluginKey,\n }),\n\n props: createSuggestionProps({\n pluginKey,\n decorationTag,\n decorationClass,\n decorationContent,\n decorationEmptyClass,\n renderer,\n dispatchExit,\n }),\n })\n}\n\n/**\n * Programmatically exit a suggestion plugin by dispatching a metadata-only\n * transaction. This is the safe, recommended API to remove suggestion\n * decorations without touching the document or causing mapping errors.\n */\nexport function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { Range } from '@tiptap/core'\nimport { escapeForRegEx } from '@tiptap/core'\nimport type { ResolvedPos } from '@tiptap/pm/model'\n\nexport interface Trigger {\n char: string\n allowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n $position: ResolvedPos\n}\n\nexport type SuggestionMatch = {\n range: Range\n query: string\n text: string\n} | null\n\nexport function findSuggestionMatch(config: Trigger): SuggestionMatch {\n const {\n char,\n allowSpaces: allowSpacesOption,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position,\n } = config\n\n const allowSpaces = allowSpacesOption && !allowToIncludeChar\n\n const escapedChar = escapeForRegEx(char)\n const suffix = new RegExp(`\\\\s${escapedChar}$`)\n const prefix = startOfLine ? '^' : ''\n const finalEscapedChar = allowToIncludeChar ? '' : escapedChar\n const regexp = allowSpaces\n ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, 'gm')\n : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, 'gm')\n\n const text = $position.nodeBefore?.isText && $position.nodeBefore.text\n\n if (!text) {\n return null\n }\n\n const textFrom = $position.pos - text.length\n const match = Array.from(text.matchAll(regexp)).pop()\n\n if (!match || match.input === undefined || match.index === undefined) {\n return null\n }\n\n // JavaScript doesn't have lookbehinds. This hacks a check that first character\n // is a space or the start of the line\n const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)\n const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\\0]?$`).test(matchPrefix)\n\n if (allowedPrefixes !== null && !matchPrefixIsAllowed) {\n return null\n }\n\n // The absolute position of the match in the document\n const from = textFrom + match.index\n let to = from + match[0].length\n\n // Edge case handling; if spaces are allowed and we're directly in between\n // two triggers\n if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n match[0] += ' '\n to += 1\n }\n\n // If the $position is located within the matched substring, return that range\n if (from < $position.pos && to >= $position.pos) {\n return {\n range: {\n from,\n to,\n },\n query: match[0].slice(char.length),\n text: match[0],\n }\n }\n\n return null\n}\n","import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport type { SuggestionMatch } from './findSuggestionMatch.js'\nimport type { SuggestionOptions, SuggestionPluginState } from './types.js'\n\n/**\n * Returns true if the transaction inserted any whitespace or newline character.\n * Used to determine when a dismissed suggestion should become active again.\n */\nexport function hasInsertedWhitespace(transaction: Transaction): boolean {\n if (!transaction.docChanged) {\n return false\n }\n return transaction.steps.some(step => {\n const slice = (step as any).slice\n if (!slice?.content) {\n return false\n }\n // textBetween with '\\n' as block separator catches both inline spaces and newlines\n const inserted = slice.content.textBetween(0, slice.content.size, '\\n')\n return /\\s/.test(inserted)\n })\n}\n\n/**\n * Gets the DOM rectangle corresponding to the current editor cursor anchor position.\n * Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object.\n */\nexport function getAnchorClientRect(editor: Editor): () => DOMRect | null {\n return () => {\n const pos = editor.state.selection.$anchor.pos\n const coords = editor.view.coordsAtPos(pos)\n const { top, right, bottom, left } = coords\n\n try {\n return new DOMRect(left, top, right - left, bottom - top)\n } catch {\n return null\n }\n }\n}\n\n/**\n * Creates a clientRect callback for a given decoration node.\n * Returns the anchor rect when no decoration node is present.\n * Uses the pluginKey's state to resolve the current decoration node on demand.\n */\nexport function clientRectFor(\n editor: Editor,\n view: EditorView,\n decorationNode: Element | null,\n pluginKey: PluginKey,\n): () => DOMRect | null {\n if (!decorationNode) {\n return getAnchorClientRect(editor)\n }\n\n return () => {\n const state: SuggestionPluginState = pluginKey.getState(editor.state) as any\n const decorationId = state?.decorationId\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n}\n\n/**\n * Determines whether a dismissed suggestion should stay dismissed.\n * Returns `true` (keep dismissed) or `false` (allow reactivation).\n */\nexport function shouldKeepDismissed({\n match,\n dismissedRange,\n state,\n transaction,\n editor,\n shouldResetDismissed,\n effectiveAllowSpaces,\n}: {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n editor: Editor\n shouldResetDismissed?: SuggestionOptions['shouldResetDismissed']\n effectiveAllowSpaces: boolean\n}): boolean {\n if (\n shouldResetDismissed?.({\n editor,\n state,\n range: dismissedRange,\n match,\n transaction,\n allowSpaces: effectiveAllowSpaces,\n })\n ) {\n return false\n }\n\n if (effectiveAllowSpaces) {\n return match.range.from === dismissedRange.from\n }\n\n return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)\n}\n\n/**\n * Dispatch an exit of the suggestion plugin by dispatching a metadata-only\n * transaction to clear the plugin state. The renderer's onExit hook is NOT\n * called here — it fires via the plugin view's stopped transition, which\n * builds SuggestionProps consistently with the normal lifecycle.\n *\n * This prevents a double onExit call (one from dispatchExit, one from the\n * view's update) and keeps exitSuggestion consistent with Escape-triggered\n * exits.\n */\nexport function dispatchExit({\n view,\n pluginKeyRef,\n}: {\n view: EditorView\n pluginKeyRef: PluginKey\n}): void {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { EditorState, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nimport type { SuggestionKeyDownProps, SuggestionPluginState } from '../types.js'\n\n/**\n * Creates the `props` object for the suggestion ProseMirror plugin.\n * Contains `handleKeyDown` for keyboard handling and `decorations`\n * for rendering the suggestion highlight.\n */\nexport interface CreateSuggestionPropsOptions {\n pluginKey: PluginKey\n decorationTag: string\n decorationClass: string\n decorationContent: string\n decorationEmptyClass: string\n renderer: { onKeyDown?: (props: SuggestionKeyDownProps) => boolean } | undefined\n dispatchExit: (view: EditorView) => void\n}\n\n/**\n * Creates the `props` object for the suggestion ProseMirror plugin.\n * Contains `handleKeyDown` for keyboard handling and `decorations`\n * for rendering the suggestion highlight.\n */\nexport function createSuggestionProps({\n pluginKey,\n decorationTag,\n decorationClass,\n decorationContent,\n decorationEmptyClass,\n renderer,\n dispatchExit,\n}: CreateSuggestionPropsOptions) {\n return {\n /**\n * Call the keydown hook if suggestion is active.\n */\n handleKeyDown(view: EditorView, event: KeyboardEvent) {\n const state: SuggestionPluginState = pluginKey.getState(view.state) as any\n\n if (!state.active) {\n return false\n }\n\n // If Escape is pressed, call onKeyDown and dispatch a metadata-only\n // transaction to unset the suggestion state. This provides a safe\n // and deterministic way to exit the suggestion without altering the\n // document (avoids transaction mapping/mismatch issues).\n if (event.key === 'Escape' || event.key === 'Esc') {\n // Allow the consumer to react to Escape, but always clear the\n // suggestion state afterward so the decoration is removed too.\n renderer?.onKeyDown?.({ view, event, range: state.range })\n\n // dispatch metadata-only transaction to unset the plugin state\n dispatchExit(view)\n\n return true\n }\n\n const handled = renderer?.onKeyDown?.({ view, event, range: state.range }) || false\n return handled\n },\n\n /**\n * Setup decorator on the currently active suggestion.\n */\n decorations(state: EditorState) {\n const pluginState: SuggestionPluginState = pluginKey.getState(state) as any\n const { active, range, decorationId, query } = pluginState\n\n if (!active) {\n return null\n }\n\n const isEmpty = !query?.length\n const classNames = [decorationClass]\n\n if (isEmpty) {\n classNames.push(decorationEmptyClass)\n }\n\n return DecorationSet.create(state.doc, [\n Decoration.inline(range.from, range.to, {\n nodeName: decorationTag,\n class: classNames.join(' '),\n 'data-decoration-id': decorationId || undefined,\n 'data-decoration-content': decorationContent,\n }),\n ])\n },\n }\n}\n","import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'\n\nimport type {\n findSuggestionMatch as defaultFindSuggestionMatch,\n SuggestionMatch,\n} from '../findSuggestionMatch.js'\nimport type { SuggestionOptions, SuggestionPluginState } from '../types.js'\n\nexport interface CreateSuggestionStateOptions {\n editor: Editor\n char: string\n effectiveAllowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n findSuggestionMatch: typeof defaultFindSuggestionMatch\n allow: Exclude<SuggestionOptions['allow'], undefined>\n shouldShow?: SuggestionOptions['shouldShow']\n shouldKeepDismissed: (props: {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n }) => boolean\n pluginKey: PluginKey\n}\n\n/**\n * Creates the `state` object for the suggestion ProseMirror plugin.\n * Contains `init()` and `apply()` for managing the plugin's internal state\n * across transactions.\n */\nexport function createSuggestionState({\n editor,\n char,\n effectiveAllowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n findSuggestionMatch,\n allow,\n shouldShow,\n shouldKeepDismissed,\n pluginKey,\n}: CreateSuggestionStateOptions) {\n return {\n /**\n * Initialize the plugin's internal state.\n */\n init(): SuggestionPluginState {\n return {\n active: false,\n range: { from: 0, to: 0 },\n query: null,\n text: null,\n composing: false,\n dismissedRange: null,\n }\n },\n\n /**\n * Apply changes to the plugin state from a view transaction.\n */\n apply(\n transaction: Transaction,\n prev: SuggestionPluginState,\n _oldState: EditorState,\n state: EditorState,\n ): SuggestionPluginState {\n const { isEditable } = editor\n const { composing } = editor.view\n const { selection } = transaction\n const { empty, from } = selection\n const next = { ...prev }\n\n // If a transaction carries the exit meta for this plugin, immediately\n // deactivate the suggestion. This allows metadata-only transactions\n // (dispatched by escape or programmatic exit) to deterministically\n // clear decorations without changing the document.\n const meta = transaction.getMeta(pluginKey)\n if (meta && meta.exit) {\n next.active = false\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange\n\n return next\n }\n\n next.composing = composing\n\n if (transaction.docChanged && next.dismissedRange !== null) {\n next.dismissedRange = {\n from: transaction.mapping.map(next.dismissedRange.from),\n to: transaction.mapping.map(next.dismissedRange.to),\n }\n }\n\n // We can only be suggesting if the view is editable, and:\n // * there is no selection, or\n // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)\n if (isEditable && (empty || editor.view.composing)) {\n // Reset active state if we just left the previous suggestion range\n if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {\n next.active = false\n }\n\n // Try to match against where our cursor currently is\n const match = findSuggestionMatch({\n char,\n allowSpaces: effectiveAllowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position: selection.$from,\n })\n const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`\n\n // If we found a match, update the current state to show it\n if (\n match &&\n allow({\n editor,\n state,\n range: match.range,\n isActive: prev.active,\n }) &&\n (!shouldShow ||\n shouldShow({\n editor,\n range: match.range,\n query: match.query,\n text: match.text,\n transaction,\n }))\n ) {\n if (\n next.dismissedRange !== null &&\n !shouldKeepDismissed({\n match,\n dismissedRange: next.dismissedRange,\n state,\n transaction,\n })\n ) {\n next.dismissedRange = null\n }\n\n if (next.dismissedRange === null) {\n next.active = true\n next.decorationId = prev.decorationId || decorationId\n next.range = match.range\n next.query = match.query\n next.text = match.text\n } else {\n next.active = false\n }\n } else {\n if (!match) {\n next.dismissedRange = null\n }\n next.active = false\n }\n } else {\n next.active = false\n }\n\n // Make sure to empty the range if suggestion is inactive\n if (!next.active) {\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n }\n\n return next\n },\n }\n}\n","import type { Editor } from '@tiptap/core'\n\nimport type { SuggestionOptions } from '../types.js'\n\nexport interface CreateSuggestionAsyncRequestManagerOptions<I = any> {\n editor: Editor\n items: NonNullable<SuggestionOptions<I>['items']>\n}\n\ntype AsyncRequestResult<I> =\n | { status: 'resolved'; items: I[] }\n | { status: 'aborted' }\n | { status: 'error' }\n\nexport function createSuggestionAsyncRequestManager<I = any>({\n editor,\n items,\n}: CreateSuggestionAsyncRequestManagerOptions<I>) {\n let abortController: AbortController | null = null\n let debounceTimer: ReturnType<typeof setTimeout> | null = null\n let debounceResolve: (() => void) | null = null\n\n const clearDebounceTimer = () => {\n if (debounceTimer !== null) {\n clearTimeout(debounceTimer)\n debounceTimer = null\n }\n\n debounceResolve?.()\n debounceResolve = null\n }\n\n const waitForDebounce = (delay: number) => {\n return new Promise<void>(resolve => {\n debounceResolve = resolve\n debounceTimer = setTimeout(() => {\n debounceTimer = null\n const pendingResolve = debounceResolve\n debounceResolve = null\n pendingResolve?.()\n }, delay)\n })\n }\n\n const abort = () => {\n abortController?.abort()\n clearDebounceTimer()\n abortController = null\n }\n\n const fetch = async (query: string, debounce: number): Promise<AsyncRequestResult<I>> => {\n abort()\n abortController = new AbortController()\n const controller = abortController\n\n if (debounce > 0) {\n await waitForDebounce(debounce)\n }\n\n if (abortController !== controller || controller.signal.aborted) {\n return { status: 'aborted' }\n }\n\n try {\n const result = await items({\n editor,\n query,\n signal: controller.signal,\n })\n\n if (abortController !== controller || controller.signal.aborted) {\n return { status: 'aborted' }\n }\n\n return { status: 'resolved', items: result }\n } catch {\n if (abortController !== controller || controller.signal.aborted) {\n return { status: 'aborted' }\n }\n\n return { status: 'error' }\n }\n }\n\n return {\n abort,\n fetch,\n }\n}\n","import type { Middleware, VirtualElement } from '@floating-ui/dom'\nimport {\n autoUpdate,\n computePosition,\n flip as floatingUiFlip,\n offset as floatingUiOffset,\n} from '@floating-ui/dom'\n\nimport type {\n SuggestionFloatingUiConfig,\n SuggestionFloatingUiOptions,\n SuggestionMount,\n SuggestionPlacement,\n} from '../types.js'\n\nexport interface CreateSuggestionFloatingUiConfigOptions {\n placement: SuggestionPlacement\n offset: { mainAxis?: number; crossAxis?: number }\n flip: boolean\n floatingUi?: SuggestionFloatingUiOptions\n}\n\nexport function createSuggestionFloatingUiConfig({\n placement,\n offset,\n flip,\n floatingUi,\n}: CreateSuggestionFloatingUiConfigOptions): SuggestionFloatingUiConfig {\n const middleware: Middleware[] = [\n floatingUiOffset({\n mainAxis: offset.mainAxis ?? 4,\n crossAxis: offset.crossAxis ?? 0,\n }),\n ]\n\n if (flip) {\n middleware.push(floatingUiFlip())\n }\n\n if (floatingUi?.middleware?.length) {\n middleware.push(...floatingUi.middleware)\n }\n\n return {\n placement,\n strategy: floatingUi?.strategy ?? 'absolute',\n middleware,\n }\n}\n\nexport interface CreateMountOptions {\n /** Returns the current cursor/anchor rect the popup should track. */\n getReferenceRect: () => DOMRect | null\n /**\n * An element inside the editor's layout/scroll context. Floating UI walks up\n * from here to discover the scroll ancestors (and the window) to observe, so\n * the scroll container does not need to be configured manually.\n */\n contextElement: Element\n /** Resolved Floating UI config (placement, strategy, middleware). */\n config: SuggestionFloatingUiConfig\n /**\n * CSS selector or element the popup should be mounted into. Defaults to\n * `document.body`. Used to portal the popup inside dialogs/modals so it\n * renders on top of (and clips within) the right context.\n */\n container?: string | HTMLElement\n /**\n * When `true`, a pointerdown outside both the popup and the editor dismisses\n * the suggestion. Wired up and torn down alongside the mounted element.\n */\n dismissOnOutsideClick: boolean\n /** Dismisses the active suggestion (used by outside-click handling). */\n dismiss: () => void\n}\n\n/**\n * Resolves a container option (selector or element) to a mount target,\n * falling back to `document.body` when it can't be resolved.\n */\nfunction resolveContainer(container?: string | HTMLElement): HTMLElement {\n if (container instanceof HTMLElement) {\n return container\n }\n\n if (typeof container === 'string') {\n try {\n // `container` is consumer-provided; an invalid selector throws a\n // DOMException, so fall back to document.body instead of crashing.\n const found = document.querySelector<HTMLElement>(container)\n\n if (found) {\n return found\n }\n } catch {\n return document.body\n }\n }\n\n return document.body\n}\n\n/**\n * Builds the `mount` function handed to the renderer on `SuggestionProps`.\n *\n * Mounts the popup into the container, then wires Floating UI's `autoUpdate`\n * against a virtual reference that re-reads the live cursor rect, so the popup\n * stays anchored across scroll, resize, and layout shifts without the consumer\n * attaching any listeners. The returned `unmount` tears all of that down.\n */\nexport function createMount({\n getReferenceRect,\n contextElement,\n config,\n container,\n dismissOnOutsideClick,\n dismiss,\n}: CreateMountOptions): SuggestionMount {\n return (element, options = {}) => {\n const reference: VirtualElement = {\n getBoundingClientRect: () => getReferenceRect() ?? new DOMRect(),\n contextElement,\n }\n\n let positioned = false\n\n // Mount the popup into the container (default `document.body`) unless the\n // consumer already placed it in the DOM themselves — in which case we leave\n // mounting (and unmounting) to them.\n const mountedByUs = !element.isConnected\n\n if (mountedByUs) {\n resolveContainer(container).appendChild(element)\n }\n\n // Hide the element until the first measurement resolves so it doesn't flash\n // at its initial coordinates. Skipped when the consumer owns applying the\n // position via `onPosition`.\n if (!options.onPosition) {\n element.style.visibility = 'hidden'\n element.style.width = 'max-content'\n }\n\n const update = () => {\n computePosition(reference, element, {\n placement: config.placement,\n strategy: config.strategy,\n middleware: config.middleware,\n }).then(({ x, y, placement, strategy }) => {\n if (options.onPosition) {\n options.onPosition({ x, y, placement: placement as SuggestionPlacement, strategy })\n return\n }\n\n Object.assign(element.style, {\n position: strategy,\n left: `${x}px`,\n top: `${y}px`,\n })\n\n if (!positioned) {\n positioned = true\n element.style.visibility = ''\n }\n })\n }\n\n const cleanupAutoUpdate = autoUpdate(reference, element, update, options.autoUpdate)\n\n // Dismiss when the user interacts outside both the popup and the editor.\n // Capture phase so a parent that stops propagation can't swallow it.\n let onOutsidePointerDown: ((event: PointerEvent) => void) | undefined\n\n if (dismissOnOutsideClick) {\n onOutsidePointerDown = event => {\n const target = event.target\n\n if (\n !(target instanceof Node) ||\n element.contains(target) ||\n contextElement.contains(target)\n ) {\n return\n }\n\n dismiss()\n }\n\n document.addEventListener('pointerdown', onOutsidePointerDown, true)\n }\n\n return () => {\n cleanupAutoUpdate()\n\n if (onOutsidePointerDown) {\n document.removeEventListener('pointerdown', onOutsidePointerDown, true)\n }\n\n if (mountedByUs) {\n element.remove()\n }\n }\n }\n}\n","import type { Editor } from '@tiptap/core'\nimport type { EditorState, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport type {\n PluginState,\n SuggestionFloatingUiOptions,\n SuggestionOptions,\n SuggestionPlacement,\n SuggestionProps,\n} from '../types.js'\nimport { createSuggestionAsyncRequestManager } from './async.js'\nimport { createMount, createSuggestionFloatingUiConfig } from './floating-ui.js'\n\nexport interface CreateSuggestionViewOptions {\n editor: Editor\n pluginKey: PluginKey<PluginState>\n items: NonNullable<SuggestionOptions['items']>\n renderer: ReturnType<NonNullable<SuggestionOptions['render']>> | undefined\n minQueryLength: number\n debounce: number\n initialItems?: any[]\n placement: SuggestionPlacement\n offset: { mainAxis?: number; crossAxis?: number }\n container?: string | HTMLElement\n flip: boolean\n floatingUi?: SuggestionFloatingUiOptions\n dismissOnOutsideClick: boolean\n command: NonNullable<SuggestionOptions['command']>\n clientRectFor: (view: EditorView, decorationNode: Element | null) => () => DOMRect | null\n dispatchExit: (view: EditorView) => void\n}\n\n/**\n * Creates the `view` object for the suggestion ProseMirror plugin.\n *\n * Manages the async lifecycle: tracks state transitions, calls renderer hooks,\n * fetches items with debounce and AbortController support.\n *\n * 1. Tracks plugin state transitions (started, updated, stopped) to determine when to call renderer hooks.\n * 2. Calls `onBeforeStart`, `onBeforeUpdate`, `onStart` before fetching to allow the renderer to prepare for first render\n * 3. Manages async fetching of suggestion items with support for debouncing and aborting in-flight requests\n * 4. Calls `onUpdate` after fetching new items to update the renderer with the latest data\n * 5. At the end calls a final `onExit` or `onUpdate` to allow the renderer to clean up or finalize the state\n */\nexport function createSuggestionView({\n editor,\n pluginKey,\n items,\n renderer,\n minQueryLength,\n debounce,\n initialItems,\n placement,\n offset: offsetOption,\n container,\n flip,\n floatingUi,\n dismissOnOutsideClick,\n command,\n clientRectFor,\n dispatchExit,\n}: CreateSuggestionViewOptions) {\n let props: SuggestionProps | undefined\n const asyncRequest = createSuggestionAsyncRequestManager({\n editor,\n items,\n })\n const floatingUiConfig = createSuggestionFloatingUiConfig({\n placement,\n offset: offsetOption,\n flip,\n floatingUi,\n })\n\n function dispatchStateUpdate(\n state: 'started' | 'updated' | 'stopped',\n dispatchProps: SuggestionProps,\n ) {\n switch (state) {\n case 'started':\n renderer?.onStart?.(dispatchProps)\n break\n case 'updated':\n renderer?.onUpdate?.(dispatchProps)\n break\n case 'stopped':\n renderer?.onExit?.(dispatchProps)\n break\n default:\n break\n }\n }\n\n return {\n update: async (view: EditorView, prevState: EditorState) => {\n const prev = pluginKey.getState(prevState)\n const next = pluginKey.getState(view.state)\n\n if (!prev || !next) {\n return\n }\n\n let currentState: 'started' | 'updated' | 'stopped' | null = null\n const queryChanged = prev.query !== next.query\n const textChanged = prev.text !== next.text\n const rangeChanged = prev.range.from !== next.range.from || prev.range.to !== next.range.to\n const effectiveQueryChanged = queryChanged || textChanged || rangeChanged\n\n if (!prev.active && next.active) {\n currentState = 'started'\n } else if (prev.active && !next.active) {\n currentState = 'stopped'\n } else if (next.active && effectiveQueryChanged) {\n currentState = 'updated'\n } else {\n return\n }\n\n const state = currentState === 'stopped' ? prev : next\n const decorationNode = view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n const clientRect = clientRectFor(view, decorationNode)\n\n const exceedsMinQueryLength =\n minQueryLength === 0 || (state.query ? state.query.length >= minQueryLength : false)\n const willFetch =\n (currentState === 'started' || currentState === 'updated') && exceedsMinQueryLength\n\n props = {\n editor,\n range: state.range,\n query: state.query || '',\n text: state.text || '',\n items: initialItems ?? [],\n command: commandProps => {\n return command({\n editor,\n range: state.range,\n props: commandProps,\n })\n },\n decorationNode,\n clientRect,\n loading: willFetch,\n placement,\n offset: { mainAxis: offsetOption.mainAxis ?? 4, crossAxis: offsetOption.crossAxis ?? 0 },\n container,\n flip,\n floatingUi: floatingUiConfig,\n mount: createMount({\n getReferenceRect: clientRect,\n contextElement: view.dom,\n config: floatingUiConfig,\n container,\n dismissOnOutsideClick,\n dismiss: () => dispatchExit(editor.view),\n }),\n }\n\n if (currentState === 'started') {\n renderer?.onBeforeStart?.(props)\n }\n\n if (currentState === 'updated') {\n renderer?.onBeforeUpdate?.(props)\n }\n\n // we run the start before we fetch\n // to allow for the component to render immediately\n if (currentState === 'started') {\n dispatchStateUpdate(currentState, props)\n }\n\n if (currentState === 'started' || currentState === 'updated') {\n if (!willFetch) {\n // Abort any in-flight request so stale results don't overwrite\n asyncRequest.abort()\n props = { ...props, items: initialItems ?? [], loading: false }\n } else {\n // update the renderer with loading state before we start the async fetch\n props = { ...props, items: initialItems ?? [], loading: true }\n currentState = 'updated'\n dispatchStateUpdate(currentState, props)\n\n const result = await asyncRequest.fetch(state.query || '', debounce)\n\n if (result.status === 'aborted') {\n return\n }\n\n // Re-check plugin state because the suggestion may have been dismissed\n const currentPluginState = pluginKey.getState(view.state)\n if (!currentPluginState?.active) {\n asyncRequest.abort()\n\n return\n }\n\n props =\n result.status === 'resolved'\n ? {\n ...props,\n items: result.items,\n loading: false,\n }\n : {\n ...props,\n loading: false,\n }\n }\n }\n\n if (currentState === 'stopped') {\n // stop running updates immediately and call onExit to allow the renderer to clean up\n asyncRequest.abort()\n dispatchStateUpdate(currentState, props)\n props = undefined\n return\n }\n\n if (currentState === 'updated') {\n dispatchStateUpdate(currentState, props)\n }\n },\n\n destroy: () => {\n asyncRequest.abort()\n\n if (!props) {\n return\n }\n\n renderer?.onExit?.(props)\n },\n }\n}\n","import { exitSuggestion, Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport { exitSuggestion }\n\nexport default Suggestion\n"],"mappings":"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","names":["dispatchExit","findSuggestionMatch","shouldKeepDismissed","clientRectFor","dispatchExit","findSuggestionMatch","clientRectFor","shouldKeepDismissed","dispatchExit"]}
{
"name": "@tiptap/suggestion",
"version": "3.26.1",
"version": "3.27.0",
"description": "suggestion plugin for tiptap",

@@ -16,2 +16,5 @@ "keywords": [

},
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"funding": {

@@ -40,8 +43,10 @@ "type": "github",

"devDependencies": {
"@tiptap/core": "^3.26.1",
"@tiptap/pm": "^3.26.1"
"@floating-ui/dom": "^1.6.12",
"@tiptap/core": "^3.27.0",
"@tiptap/pm": "^3.27.0"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.27.0",
"@tiptap/pm": "3.27.0"
},

@@ -48,0 +53,0 @@ "scripts": {

@@ -0,1 +1,2 @@

import type { Middleware } from '@floating-ui/dom'
import { Editor, Extension } from '@tiptap/core'

@@ -412,1 +413,837 @@ import StarterKit from '@tiptap/starter-kit'

})
describe('suggestion minQueryLength', () => {
it('should not call items when query is shorter than minQueryLength', async () => {
const items = vi.fn().mockReturnValue([])
const onStart = vi.fn()
const onUpdate = vi.fn()
const onExit = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-min-query',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
minQueryLength: 2,
items,
render: () => ({ onStart, onUpdate, onExit }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
// Type @a — query "a" is too short (length 1 < 2)
editor.chain().insertContent('@a').run()
await Promise.resolve()
expect(onStart).toHaveBeenCalledTimes(1)
// items should not have been called because query.length < minQueryLength
expect(items).not.toHaveBeenCalled()
// The props passed to onStart should have items: []
expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ items: [] }))
// Continue typing to reach minQueryLength
editor.chain().insertContent('b').run()
await Promise.resolve()
// items should be called now with query 'ab'
expect(items).toHaveBeenCalledWith(
expect.objectContaining({
editor: expect.any(Object),
query: 'ab',
}),
)
editor.destroy()
})
})
describe('suggestion initialItems', () => {
it('should pass initialItems to onBeforeStart/onBeforeUpdate and resolved items to onStart/onUpdate', async () => {
const initialItems = [{ id: 1, label: 'Popular' }]
const resolvedItems = [{ id: 2, label: 'Filtered' }]
const items = vi.fn().mockResolvedValue(resolvedItems)
const onBeforeStart = vi.fn()
const onBeforeUpdate = vi.fn()
const onStart = vi.fn()
const onUpdate = vi.fn()
const onExit = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-initial-items',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
initialItems,
items,
render: () => ({ onBeforeStart, onBeforeUpdate, onStart, onUpdate, onExit }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
// Type @ to start suggestion
editor.chain().insertContent('@').run()
await Promise.resolve()
await Promise.resolve()
// onBeforeStart should receive initialItems
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ items: initialItems }))
// onStart mounts immediately with the initial items while loading
expect(onStart).toHaveBeenLastCalledWith(
expect.objectContaining({ items: initialItems, loading: true }),
)
// onUpdate receives the async-resolved items
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ items: resolvedItems }))
// items() should still have been called
expect(items).toHaveBeenCalledWith(
expect.objectContaining({
editor: expect.any(Object),
query: '',
}),
)
// Reset mocks for the update phase
items.mockClear()
onBeforeUpdate.mockClear()
onUpdate.mockClear()
// Type another character to trigger an update
editor.chain().insertContent('a').run()
await Promise.resolve()
await Promise.resolve()
// onBeforeUpdate should also receive initialItems
expect(onBeforeUpdate).toHaveBeenCalledWith(expect.objectContaining({ items: initialItems }))
// onUpdate should receive the async-resolved items
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ items: resolvedItems }))
expect(items).toHaveBeenCalledWith(
expect.objectContaining({
editor: expect.any(Object),
query: 'a',
}),
)
editor.destroy()
})
})
describe('suggestion loading state', () => {
it('should set loading to true in before callbacks and false after when items() is called', async () => {
const items = vi.fn().mockResolvedValue([])
const onBeforeStart = vi.fn()
const onBeforeUpdate = vi.fn()
const onStart = vi.fn()
const onUpdate = vi.fn()
const onExit = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-loading',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
items,
render: () => ({ onBeforeStart, onBeforeUpdate, onStart, onUpdate, onExit }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
// Type @ to start suggestion — triggers async items()
editor.chain().insertContent('@').run()
await Promise.resolve()
await Promise.resolve()
// onBeforeStart fires before items() resolves → loading should be true
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
// onStart fires immediately with loading enabled
expect(onStart).toHaveBeenLastCalledWith(expect.objectContaining({ loading: true }))
// onUpdate fires after items() resolves → loading should be false
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
// Type another char to trigger an update
editor.chain().insertContent('a').run()
await Promise.resolve()
await Promise.resolve()
// onBeforeUpdate fires before items() resolves → loading should be true
expect(onBeforeUpdate).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
// onUpdate fires after items() resolves → loading should be false
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
editor.destroy()
})
it('should recover when items() rejects', async () => {
const items = vi.fn().mockRejectedValue(new Error('boom'))
const onBeforeStart = vi.fn()
const onUpdate = vi.fn()
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-loading-rejects',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
items,
render: () => ({ onBeforeStart, onStart, onUpdate }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
await Promise.resolve()
expect(items).toHaveBeenCalledTimes(1)
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
editor.destroy()
})
it('should set loading to false in all callbacks when minQueryLength blocks items()', async () => {
const items = vi.fn().mockResolvedValue([])
const onBeforeStart = vi.fn()
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-loading-blocked',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
minQueryLength: 3,
items,
render: () => ({ onBeforeStart, onStart }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
// Type @a — query "a" is too short, items() won't be called
editor.chain().insertContent('@a').run()
await Promise.resolve()
// No async call happens → loading should be false
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: false }))
expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ loading: false }))
editor.destroy()
})
})
describe('suggestion AbortSignal', () => {
it('should pass signal to items() and abort previous signal on new query', async () => {
const signals: AbortSignal[] = []
let resolveFirst: (value: unknown) => void = () => {}
const items = vi.fn().mockImplementation(({ signal }) => {
signals.push(signal)
// First call returns a promise that we control
if (signals.length === 1) {
return new Promise(resolve => {
resolveFirst = resolve
})
}
// Subsequent calls resolve immediately
return []
})
const onStart = vi.fn()
const onUpdate = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-abort',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
items,
render: () => ({ onStart, onUpdate }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
// Type @ to start suggestion — first items() call starts but doesn't resolve
editor.chain().insertContent('@').run()
await Promise.resolve()
expect(items).toHaveBeenCalledTimes(1)
expect(signals[0].aborted).toBe(false)
// Type a — triggers a second items() while first is still in-flight
editor.chain().insertContent('a').run()
await Promise.resolve()
// items() should have been called a second time
expect(items).toHaveBeenCalledTimes(2)
// The first signal should be aborted
expect(signals[0].aborted).toBe(true)
// The second signal should be fresh
expect(signals[1].aborted).toBe(false)
// Clean up the hanging promise
resolveFirst([])
await Promise.resolve()
editor.destroy()
})
it('should not emit stale callbacks after a request is superseded', async () => {
const signals: AbortSignal[] = []
let resolveFirst: (value: unknown) => void = () => {}
const items = vi.fn().mockImplementation(({ signal }) => {
signals.push(signal)
if (signals.length === 1) {
return new Promise(resolve => {
resolveFirst = resolve
})
}
return []
})
const onStart = vi.fn()
const onUpdate = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-abort-stale-callbacks',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
items,
render: () => ({ onStart, onUpdate }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
editor.chain().insertContent('a').run()
await Promise.resolve()
await Promise.resolve()
const startCallsBefore = onStart.mock.calls.length
const updateCallsBefore = onUpdate.mock.calls.length
resolveFirst([])
await Promise.resolve()
await Promise.resolve()
expect(onStart.mock.calls.length).toBe(startCallsBefore)
expect(onUpdate.mock.calls.length).toBe(updateCallsBefore)
editor.destroy()
})
it('should pass signal as a property in the items callback props', async () => {
const items = vi.fn().mockImplementation(({ signal }) => {
expect(signal).toBeInstanceOf(AbortSignal)
return []
})
const MentionExtension = Extension.create({
name: 'mention-abort-prop',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
items,
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
expect(items).toHaveBeenCalled()
// The callback assertion already checked signal is an AbortSignal
editor.destroy()
})
})
describe('suggestion debounce', () => {
it('should delay the items() call by the configured debounce time', async () => {
vi.useFakeTimers()
const items = vi.fn().mockResolvedValue([])
const onBeforeStart = vi.fn()
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-debounce',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
debounce: 100,
items,
render: () => ({ onBeforeStart, onStart }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
// Type @ to trigger suggestion
editor.chain().insertContent('@').run()
// items() should not have been called yet (debounce pending)
expect(items).not.toHaveBeenCalled()
// onBeforeStart should fire immediately (not debounced)
expect(onBeforeStart).toHaveBeenCalled()
// Advance past the debounce window
await vi.advanceTimersByTimeAsync(100)
// Now items() should have been called
expect(items).toHaveBeenCalledTimes(1)
// onStart fires after items resolves
expect(onStart).toHaveBeenCalled()
vi.useRealTimers()
editor.destroy()
})
it('should cancel pending debounce work on destroy', async () => {
vi.useFakeTimers()
const items = vi.fn().mockResolvedValue([])
const onBeforeStart = vi.fn()
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-debounce-destroy',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
debounce: 100,
items,
render: () => ({ onBeforeStart, onStart }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
const startCallsBefore = onStart.mock.calls.length
editor.destroy()
await vi.advanceTimersByTimeAsync(100)
expect(items).not.toHaveBeenCalled()
expect(onStart.mock.calls.length).toBe(startCallsBefore)
vi.useRealTimers()
})
it('should reset the debounce timer on rapid typing', async () => {
vi.useFakeTimers()
const items = vi.fn().mockResolvedValue([])
const MentionExtension = Extension.create({
name: 'mention-debounce-rapid',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
debounce: 100,
items,
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
// Type @a
editor.chain().insertContent('@a').run()
await vi.advanceTimersByTimeAsync(60)
// Type b before debounce fires
editor.chain().insertContent('b').run()
await vi.advanceTimersByTimeAsync(60)
// Debounce should have reset — items not called yet
expect(items).not.toHaveBeenCalled()
// Advance past remaining debounce from last keystroke
await vi.advanceTimersByTimeAsync(100)
// Should have been called only once (not twice)
expect(items).toHaveBeenCalledTimes(1)
vi.useRealTimers()
editor.destroy()
})
})
describe('suggestion positioning options', () => {
it('should forward placement, offset, container, and flip to SuggestionProps', async () => {
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-positioning',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
placement: 'top-start',
offset: { mainAxis: 8, crossAxis: 4 },
container: '.my-container',
flip: false,
render: () => ({ onStart }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
placement: 'top-start',
offset: { mainAxis: 8, crossAxis: 4 },
container: '.my-container',
flip: false,
}),
)
editor.destroy()
})
it('should use defaults when positioning options are not set', async () => {
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-positioning-defaults',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
render: () => ({ onStart }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
placement: 'bottom-start',
offset: { mainAxis: 4, crossAxis: 0 },
flip: true,
}),
)
editor.destroy()
})
it('should pass through floatingUi config and middleware', async () => {
const customMiddleware = {
name: 'custom',
fn: vi.fn(() => ({ x: 0, y: 0 })),
} as Middleware
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-floating-ui',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
placement: 'top-start',
offset: { mainAxis: 8, crossAxis: 4 },
flip: false,
floatingUi: {
strategy: 'fixed',
middleware: [customMiddleware],
},
render: () => ({ onStart }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
floatingUi: expect.objectContaining({
placement: 'top-start',
strategy: 'fixed',
}),
}),
)
const floatingUi = onStart.mock.calls[0][0].floatingUi
expect(floatingUi.middleware).toHaveLength(2)
expect(floatingUi.middleware).toEqual(expect.arrayContaining([customMiddleware]))
editor.destroy()
})
})
describe('suggestion mount', () => {
// Captures `props.mount` from a started suggestion so each test can call it
// against a real element.
async function getMount(container?: string | HTMLElement) {
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-mount',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
container,
render: () => ({ onStart }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
return { mount: onStart.mock.calls[0][0].mount, editor }
}
it('mounts the element into document.body and removes it on unmount', async () => {
const { mount, editor } = await getMount()
const element = document.createElement('div')
const unmount = mount(element)
expect(element.parentElement).toBe(document.body)
unmount()
expect(element.isConnected).toBe(false)
editor.destroy()
})
it('mounts the element into a provided container', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const { mount, editor } = await getMount(container)
const element = document.createElement('div')
const unmount = mount(element)
expect(element.parentElement).toBe(container)
unmount()
container.remove()
editor.destroy()
})
it('leaves an already-mounted element in place (escape hatch)', async () => {
const { mount, editor } = await getMount()
const element = document.createElement('div')
const host = document.createElement('div')
host.appendChild(element)
document.body.appendChild(host)
const unmount = mount(element)
expect(element.parentElement).toBe(host)
// We did not mount it, so unmount must not remove it.
unmount()
expect(element.parentElement).toBe(host)
host.remove()
editor.destroy()
})
})
describe('suggestion outside click', () => {
async function setup(dismissOnOutsideClick?: boolean) {
const onStart = vi.fn()
const MentionExtension = Extension.create({
name: 'mention-outside-click',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
dismissOnOutsideClick,
render: () => ({ onStart }),
}),
]
},
})
const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})
editor.chain().insertContent('@').run()
await Promise.resolve()
const mount = onStart.mock.calls[0][0].mount
const isActive = () => SuggestionPluginKey.getState(editor.state)?.active === true
return { mount, editor, isActive }
}
it('dismisses when clicking outside the popup and editor', async () => {
const { mount, editor, isActive } = await setup()
const element = document.createElement('div')
const unmount = mount(element)
expect(isActive()).toBe(true)
const outside = document.createElement('div')
document.body.appendChild(outside)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(isActive()).toBe(false)
unmount()
outside.remove()
editor.destroy()
})
it('does not dismiss when clicking inside the popup', async () => {
const { mount, editor, isActive } = await setup()
const element = document.createElement('div')
const unmount = mount(element)
element.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(isActive()).toBe(true)
unmount()
editor.destroy()
})
it('does not attach the listener when dismissOnOutsideClick is false', async () => {
const { mount, editor, isActive } = await setup(false)
const element = document.createElement('div')
const unmount = mount(element)
const outside = document.createElement('div')
document.body.appendChild(outside)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(isActive()).toBe(true)
unmount()
outside.remove()
editor.destroy()
})
})

@@ -1,241 +0,39 @@

import type { Editor, Range } from '@tiptap/core'
import type { Range } from '@tiptap/core'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { SuggestionMatch } from './findSuggestionMatch.js'
import { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'
import {
clientRectFor as clientRectForHelper,
dispatchExit as dispatchExitHelper,
shouldKeepDismissed as shouldKeepDismissedHelper,
} from './helpers.js'
import { createSuggestionProps } from './plugin/props.js'
import { createSuggestionState } from './plugin/state.js'
import { createSuggestionView } from './plugin/view.js'
import type { SuggestionOptions } from './types.js'
/**
* Returns true if the transaction inserted any whitespace or newline character.
* Used to determine when a dismissed suggestion should become active again.
*/
function hasInsertedWhitespace(transaction: Transaction): boolean {
if (!transaction.docChanged) {
return false
}
return transaction.steps.some(step => {
const slice = (step as any).slice
if (!slice?.content) {
return false
}
// textBetween with '\n' as block separator catches both inline spaces and newlines
const inserted = slice.content.textBetween(0, slice.content.size, '\n')
return /\s/.test(inserted)
})
}
export type {
SuggestionFloatingUiConfig,
SuggestionFloatingUiOptions,
SuggestionKeyDownProps,
SuggestionMount,
SuggestionMountOptions,
SuggestionOptions,
SuggestionPlacement,
SuggestionPositionData,
SuggestionProps,
} from './types.js'
export interface SuggestionOptions<I = any, TSelected = any> {
/**
* The plugin key for the suggestion plugin.
* @default 'suggestion'
* @example 'mention'
*/
pluginKey?: PluginKey
export const SuggestionPluginKey = new PluginKey('suggestion')
/**
* A function that returns a boolean to indicate if the suggestion should be active.
* This is useful to prevent suggestions from opening for remote users in collaborative environments.
* @param props The props object.
* @param props.editor The editor instance.
* @param props.range The range of the suggestion.
* @param props.query The current suggestion query.
* @param props.text The current suggestion text.
* @param props.transaction The current transaction.
* @returns {boolean}
* @example ({ transaction }) => isChangeOrigin(transaction)
*/
shouldShow?: (props: {
editor: Editor
range: Range
query: string
text: string
transaction: Transaction
}) => boolean
/**
* Controls when a dismissed suggestion becomes active again.
* Return `true` to clear the dismissed context for the current transaction.
*/
shouldResetDismissed?: (props: {
editor: Editor
state: EditorState
range: Range
match: Exclude<SuggestionMatch, null>
transaction: Transaction
allowSpaces: boolean
}) => boolean
/**
* The editor instance.
* @default null
*/
editor: Editor
/**
* The character that triggers the suggestion.
* @default '@'
* @example '#'
*/
char?: string
/**
* Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.
* @default false
* @example true
*/
allowSpaces?: boolean
/**
* Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.
* @default false
*/
allowToIncludeChar?: boolean
/**
* Allow prefixes in the suggestion query.
* @default [' ']
* @example [' ', '@']
*/
allowedPrefixes?: string[] | null
/**
* Only match suggestions at the start of the line.
* @default false
* @example true
*/
startOfLine?: boolean
/**
* The tag name of the decoration node.
* @default 'span'
* @example 'div'
*/
decorationTag?: string
/**
* The class name of the decoration node.
* @default 'suggestion'
* @example 'mention'
*/
decorationClass?: string
/**
* Creates a decoration with the provided content.
* @param decorationContent - The content to display in the decoration
* @default "" - Creates an empty decoration if no content provided
*/
decorationContent?: string
/**
* The class name of the decoration node when it is empty.
* @default 'is-empty'
* @example 'is-empty'
*/
decorationEmptyClass?: string
/**
* A function that is called when a suggestion is selected.
* @param props The props object.
* @param props.editor The editor instance.
* @param props.range The range of the suggestion.
* @param props.props The props of the selected suggestion.
* @returns void
* @example ({ editor, range, props }) => { props.command(props.props) }
*/
command?: (props: { editor: Editor; range: Range; props: TSelected }) => void
/**
* A function that returns the suggestion items in form of an array.
* @param props The props object.
* @param props.editor The editor instance.
* @param props.query The current suggestion query.
* @returns An array of suggestion items.
* @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]
*/
items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>
/**
* The render function for the suggestion.
* @returns An object with render functions.
*/
render?: () => {
onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void
onStart?: (props: SuggestionProps<I, TSelected>) => void
onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void
onUpdate?: (props: SuggestionProps<I, TSelected>) => void
onExit?: (props: SuggestionProps<I, TSelected>) => void
onKeyDown?: (props: SuggestionKeyDownProps) => boolean
}
/**
* A function that returns a boolean to indicate if the suggestion should be active.
* @param props The props object.
* @returns {boolean}
*/
allow?: (props: {
editor: Editor
state: EditorState
range: Range
isActive?: boolean
}) => boolean
findSuggestionMatch?: typeof defaultFindSuggestionMatch
type ShouldKeepDismissedProps = {
match: Exclude<SuggestionMatch, null>
dismissedRange: Range
state: EditorState
transaction: Transaction
}
export interface SuggestionProps<I = any, TSelected = any> {
/**
* The editor instance.
*/
editor: Editor
/**
* The range of the suggestion.
*/
range: Range
/**
* The current suggestion query.
*/
query: string
/**
* The current suggestion text.
*/
text: string
/**
* The suggestion items array.
*/
items: I[]
/**
* A function that is called when a suggestion is selected.
* @param props The props object.
* @returns void
*/
command: (props: TSelected) => void
/**
* The decoration node HTML element
* @default null
*/
decorationNode: Element | null
/**
* The function that returns the client rect
* @default null
* @example () => new DOMRect(0, 0, 0, 0)
*/
clientRect?: (() => DOMRect | null) | null
}
export interface SuggestionKeyDownProps {
view: EditorView
event: KeyboardEvent
range: Range
}
export const SuggestionPluginKey = new PluginKey('suggestion')
/**

@@ -259,2 +57,11 @@ * This utility allows you to create suggestions.

items = () => [],
minQueryLength = 0,
debounce = 0,
initialItems,
placement = 'bottom-start',
offset: offsetOption = {},
container,
flip = true,
floatingUi,
dismissOnOutsideClick = true,
render = () => ({}),

@@ -266,387 +73,71 @@ allow = () => true,

}: SuggestionOptions<I, TSelected>) {
let props: SuggestionProps<I, TSelected> | undefined
const renderer = render?.()
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar
// Gets the DOM rectangle corresponding to the current editor cursor anchor position
// Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object
const getAnchorClientRect = () => {
const pos = editor.state.selection.$anchor.pos
const coords = editor.view.coordsAtPos(pos)
const { top, right, bottom, left } = coords
const clientRectFor = (view: EditorView, decorationNode: Element | null) =>
clientRectForHelper(editor, view, decorationNode, pluginKey)
try {
return new DOMRect(left, top, right - left, bottom - top)
} catch {
return null
}
// helper to check if the dismissed suggestion should stay dismissed, with access to editor and options
function shouldKeepDismissed(props: ShouldKeepDismissedProps) {
return shouldKeepDismissedHelper({
...props,
editor,
shouldResetDismissed,
effectiveAllowSpaces,
})
}
// Helper to create a clientRect callback for a given decoration node.
// Returns null when no decoration node is present. Uses the pluginKey's
// state to resolve the current decoration node on demand, avoiding a
// duplicated implementation in multiple places.
const clientRectFor = (view: EditorView, decorationNode: Element | null) => {
if (!decorationNode) {
return getAnchorClientRect
}
const dispatchExit = (view: EditorView) =>
dispatchExitHelper({
view,
pluginKeyRef: pluginKey,
})
return () => {
const state = pluginKey.getState(editor.state)
const decorationId = state?.decorationId
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`)
return new Plugin({
key: pluginKey,
return currentDecorationNode?.getBoundingClientRect() || null
}
}
const shouldKeepDismissed = ({
match,
dismissedRange,
state,
transaction,
}: {
match: Exclude<SuggestionMatch, null>
dismissedRange: Range
state: EditorState
transaction: Transaction
}) => {
if (
shouldResetDismissed?.({
view: () =>
createSuggestionView({
editor,
state,
range: dismissedRange,
match,
transaction,
allowSpaces: effectiveAllowSpaces,
})
) {
return false
}
pluginKey,
items,
renderer,
minQueryLength,
debounce,
initialItems,
placement,
offset: offsetOption,
container,
flip,
floatingUi,
dismissOnOutsideClick,
command,
clientRectFor,
dispatchExit,
}),
if (effectiveAllowSpaces) {
return match.range.from === dismissedRange.from
}
state: createSuggestionState({
editor,
char,
effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
findSuggestionMatch,
allow,
shouldShow,
shouldKeepDismissed,
pluginKey,
}),
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)
}
// small helper used internally by the view to dispatch an exit
function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {
try {
const state = pluginKey.getState(view.state)
const decorationNode = state?.decorationId
? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`)
: null
const exitProps: SuggestionProps = {
// @ts-ignore editor is available in closure
editor,
range: state?.range || { from: 0, to: 0 },
query: state?.query || null,
text: state?.text || null,
items: [],
command: commandProps => {
return command({
editor,
range: state?.range || { from: 0, to: 0 },
props: commandProps as any,
})
},
decorationNode,
clientRect: clientRectFor(view, decorationNode),
}
renderer?.onExit?.(exitProps)
} catch {
// ignore errors from consumer renderers
}
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
// Dispatch a metadata-only transaction to signal the plugin to exit
view.dispatch(tr)
}
const plugin: Plugin<any> = new Plugin({
key: pluginKey,
view() {
return {
update: async (view, prevState) => {
const prev = this.key?.getState(prevState)
const next = this.key?.getState(view.state)
// See how the state changed
const moved = prev.active && next.active && prev.range.from !== next.range.from
const started = !prev.active && next.active
const stopped = prev.active && !next.active
const changed = !started && !stopped && prev.query !== next.query
const handleStart = started || (moved && changed)
const handleChange = changed || moved
const handleExit = stopped || (moved && changed)
// Cancel when suggestion isn't active
if (!handleStart && !handleChange && !handleExit) {
return
}
const state = handleExit && !handleStart ? prev : next
const decorationNode = view.dom.querySelector(
`[data-decoration-id="${state.decorationId}"]`,
)
props = {
editor,
range: state.range,
query: state.query,
text: state.text,
items: [],
command: commandProps => {
return command({
editor,
range: state.range,
props: commandProps,
})
},
decorationNode,
clientRect: clientRectFor(view, decorationNode),
}
if (handleStart) {
renderer?.onBeforeStart?.(props)
}
if (handleChange) {
renderer?.onBeforeUpdate?.(props)
}
if (handleChange || handleStart) {
props.items = await items({
editor,
query: state.query,
})
}
if (handleExit) {
renderer?.onExit?.(props)
}
if (handleChange) {
renderer?.onUpdate?.(props)
}
if (handleStart) {
renderer?.onStart?.(props)
}
},
destroy: () => {
if (!props) {
return
}
renderer?.onExit?.(props)
},
}
},
state: {
// Initialize the plugin's internal state.
init() {
const state: {
active: boolean
range: Range
query: null | string
text: null | string
composing: boolean
decorationId?: string | null
dismissedRange: Range | null
} = {
active: false,
range: {
from: 0,
to: 0,
},
query: null,
text: null,
composing: false,
dismissedRange: null,
}
return state
},
// Apply changes to the plugin state from a view transaction.
apply(transaction, prev, _oldState, state) {
const { isEditable } = editor
const { composing } = editor.view
const { selection } = transaction
const { empty, from } = selection
const next = { ...prev }
// If a transaction carries the exit meta for this plugin, immediately
// deactivate the suggestion. This allows metadata-only transactions
// (dispatched by escape or programmatic exit) to deterministically
// clear decorations without changing the document.
const meta = transaction.getMeta(pluginKey)
if (meta && meta.exit) {
next.active = false
next.decorationId = null
next.range = { from: 0, to: 0 }
next.query = null
next.text = null
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange
return next
}
next.composing = composing
if (transaction.docChanged && next.dismissedRange !== null) {
next.dismissedRange = {
from: transaction.mapping.map(next.dismissedRange.from),
to: transaction.mapping.map(next.dismissedRange.to),
}
}
// We can only be suggesting if the view is editable, and:
// * there is no selection, or
// * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
if (isEditable && (empty || editor.view.composing)) {
// Reset active state if we just left the previous suggestion range
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
next.active = false
}
// Try to match against where our cursor currently is
const match = findSuggestionMatch({
char,
allowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
$position: selection.$from,
})
const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`
// If we found a match, update the current state to show it
if (
match &&
allow({
editor,
state,
range: match.range,
isActive: prev.active,
}) &&
(!shouldShow ||
shouldShow({
editor,
range: match.range,
query: match.query,
text: match.text,
transaction,
}))
) {
if (
next.dismissedRange !== null &&
!shouldKeepDismissed({
match,
dismissedRange: next.dismissedRange,
state,
transaction,
})
) {
next.dismissedRange = null
}
if (next.dismissedRange === null) {
next.active = true
next.decorationId = prev.decorationId ? prev.decorationId : decorationId
next.range = match.range
next.query = match.query
next.text = match.text
} else {
next.active = false
}
} else {
if (!match) {
next.dismissedRange = null
}
next.active = false
}
} else {
next.active = false
}
// Make sure to empty the range if suggestion is inactive
if (!next.active) {
next.decorationId = null
next.range = { from: 0, to: 0 }
next.query = null
next.text = null
}
return next
},
},
props: {
// Call the keydown hook if suggestion is active.
handleKeyDown(view, event) {
const { active, range } = plugin.getState(view.state)
if (!active) {
return false
}
// If Escape is pressed, call onExit and dispatch a metadata-only
// transaction to unset the suggestion state. This provides a safe
// and deterministic way to exit the suggestion without altering the
// document (avoids transaction mapping/mismatch issues).
if (event.key === 'Escape' || event.key === 'Esc') {
const state = plugin.getState(view.state)
// Allow the consumer to react to Escape, but always clear the
// suggestion state afterward so the decoration is removed too.
renderer?.onKeyDown?.({ view, event, range: state.range })
// dispatch metadata-only transaction to unset the plugin state
dispatchExit(view, pluginKey)
return true
}
const handled = renderer?.onKeyDown?.({ view, event, range }) || false
return handled
},
// Setup decorator on the currently active suggestion.
decorations(state) {
const { active, range, decorationId, query } = plugin.getState(state)
if (!active) {
return null
}
const isEmpty = !query?.length
const classNames = [decorationClass]
if (isEmpty) {
classNames.push(decorationEmptyClass)
}
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, {
nodeName: decorationTag,
class: classNames.join(' '),
'data-decoration-id': decorationId,
'data-decoration-content': decorationContent,
}),
])
},
},
props: createSuggestionProps({
pluginKey,
decorationTag,
decorationClass,
decorationContent,
decorationEmptyClass,
renderer,
dispatchExit,
}),
})
return plugin
}

@@ -653,0 +144,0 @@