@tiptap/suggestion
Advanced tools
+129
| 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) | ||
| }, | ||
| } | ||
| } |
+439
| 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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACEA,mBAAkC;AAElC,kBAA0C;;;ACH1C,kBAA+B;AAkBxB,SAAS,oBAAoB,QAAkC;AAnBtE;AAoBE,QAAM;AAAA,IACJ;AAAA,IACA,aAAa;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AAEJ,QAAM,cAAc,qBAAqB,CAAC;AAE1C,QAAM,kBAAc,4BAAe,IAAI;AACvC,QAAM,SAAS,IAAI,OAAO,MAAM,WAAW,GAAG;AAC9C,QAAM,SAAS,cAAc,MAAM;AACnC,QAAM,mBAAmB,qBAAqB,KAAK;AACnD,QAAM,SAAS,cACX,IAAI,OAAO,GAAG,MAAM,GAAG,WAAW,YAAY,gBAAgB,OAAO,IAAI,IACzE,IAAI,OAAO,GAAG,MAAM,SAAS,WAAW,QAAQ,gBAAgB,MAAM,IAAI;AAE9E,QAAM,SAAO,eAAU,eAAV,mBAAsB,WAAU,UAAU,WAAW;AAElE,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,UAAU,MAAM,KAAK;AACtC,QAAM,QAAQ,MAAM,KAAK,KAAK,SAAS,MAAM,CAAC,EAAE,IAAI;AAEpD,MAAI,CAAC,SAAS,MAAM,UAAU,UAAa,MAAM,UAAU,QAAW;AACpE,WAAO;AAAA,EACT;AAIA,QAAM,cAAc,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG,MAAM,QAAQ,CAAC,GAAG,MAAM,KAAK;AAC/E,QAAM,uBAAuB,IAAI,OAAO,KAAK,mDAAiB,KAAK,GAAG,OAAO,EAAE,KAAK,WAAW;AAE/F,MAAI,oBAAoB,QAAQ,CAAC,sBAAsB;AACrD,WAAO;AAAA,EACT;AAGA,QAAM,OAAO,WAAW,MAAM;AAC9B,MAAI,KAAK,OAAO,MAAM,CAAC,EAAE;AAIzB,MAAI,eAAe,OAAO,KAAK,KAAK,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,GAAG;AAC1D,UAAM,CAAC,KAAK;AACZ,UAAM;AAAA,EACR;AAGA,MAAI,OAAO,UAAU,OAAO,MAAM,UAAU,KAAK;AAC/C,WAAO;AAAA,MACL,OAAO;AAAA,QACL;AAAA,QACA;AAAA,MACF;AAAA,MACA,OAAO,MAAM,CAAC,EAAE,MAAM,KAAK,MAAM;AAAA,MACjC,MAAM,MAAM,CAAC;AAAA,IACf;AAAA,EACF;AAEA,SAAO;AACT;;;ADxEA,SAAS,sBAAsB,aAAmC;AAChE,MAAI,CAAC,YAAY,YAAY;AAC3B,WAAO;AAAA,EACT;AACA,SAAO,YAAY,MAAM,KAAK,UAAQ;AACpC,UAAM,QAAS,KAAa;AAC5B,QAAI,EAAC,+BAAO,UAAS;AACnB,aAAO;AAAA,IACT;AAEA,UAAM,WAAW,MAAM,QAAQ,YAAY,GAAG,MAAM,QAAQ,MAAM,IAAI;AACtE,WAAO,KAAK,KAAK,QAAQ;AAAA,EAC3B,CAAC;AACH;AAoNO,IAAM,sBAAsB,IAAI,uBAAU,YAAY;AAMtD,SAAS,WAAqC;AAAA,EACnD,YAAY;AAAA,EACZ;AAAA,EACA,OAAO;AAAA,EACP,cAAc;AAAA,EACd,qBAAqB;AAAA,EACrB,kBAAkB,CAAC,GAAG;AAAA,EACtB,cAAc;AAAA,EACd,gBAAgB;AAAA,EAChB,kBAAkB;AAAA,EAClB,oBAAoB;AAAA,EACpB,uBAAuB;AAAA,EACvB,UAAU,MAAM;AAAA,EAChB,QAAQ,MAAM,CAAC;AAAA,EACf,SAAS,OAAO,CAAC;AAAA,EACjB,QAAQ,MAAM;AAAA,EACd,qBAAAA,uBAAsB;AAAA,EACtB;AAAA,EACA;AACF,GAAoC;AAClC,MAAI;AACJ,QAAM,WAAW;AACjB,QAAM,uBAAuB,eAAe,CAAC;AAI7C,QAAM,sBAAsB,MAAM;AAChC,UAAM,MAAM,OAAO,MAAM,UAAU,QAAQ;AAC3C,UAAM,SAAS,OAAO,KAAK,YAAY,GAAG;AAC1C,UAAM,EAAE,KAAK,OAAO,QAAQ,KAAK,IAAI;AAErC,QAAI;AACF,aAAO,IAAI,QAAQ,MAAM,KAAK,QAAQ,MAAM,SAAS,GAAG;AAAA,IAC1D,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAMA,QAAM,gBAAgB,CAAC,MAAkB,mBAAmC;AAC1E,QAAI,CAAC,gBAAgB;AACnB,aAAO;AAAA,IACT;AAEA,WAAO,MAAM;AACX,YAAM,QAAQ,UAAU,SAAS,OAAO,KAAK;AAC7C,YAAM,eAAe,+BAAO;AAC5B,YAAM,wBAAwB,KAAK,IAAI,cAAc,wBAAwB,YAAY,IAAI;AAE7F,cAAO,+DAAuB,4BAA2B;AAAA,IAC3D;AAAA,EACF;AAEA,QAAM,sBAAsB,CAAC;AAAA,IAC3B;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,MAKM;AACJ,QACE,6DAAuB;AAAA,MACrB;AAAA,MACA;AAAA,MACA,OAAO;AAAA,MACP;AAAA,MACA;AAAA,MACA,aAAa;AAAA,IACf,IACA;AACA,aAAO;AAAA,IACT;AAEA,QAAI,sBAAsB;AACxB,aAAO,MAAM,MAAM,SAAS,eAAe;AAAA,IAC7C;AAEA,WAAO,MAAM,MAAM,SAAS,eAAe,QAAQ,CAAC,sBAAsB,WAAW;AAAA,EACvF;AAGA,WAAS,aAAa,MAAkB,cAAyB;AA5UnE;AA6UI,QAAI;AACF,YAAM,QAAQ,UAAU,SAAS,KAAK,KAAK;AAC3C,YAAM,kBAAiB,+BAAO,gBAC1B,KAAK,IAAI,cAAc,wBAAwB,MAAM,YAAY,IAAI,IACrE;AAEJ,YAAM,YAA6B;AAAA;AAAA,QAEjC;AAAA,QACA,QAAO,+BAAO,UAAS,EAAE,MAAM,GAAG,IAAI,EAAE;AAAA,QACxC,QAAO,+BAAO,UAAS;AAAA,QACvB,OAAM,+BAAO,SAAQ;AAAA,QACrB,OAAO,CAAC;AAAA,QACR,SAAS,kBAAgB;AACvB,iBAAO,QAAQ;AAAA,YACb;AAAA,YACA,QAAO,+BAAO,UAAS,EAAE,MAAM,GAAG,IAAI,EAAE;AAAA,YACxC,OAAO;AAAA,UACT,CAAC;AAAA,QACH;AAAA,QACA;AAAA,QACA,YAAY,cAAc,MAAM,cAAc;AAAA,MAChD;AAEA,iDAAU,WAAV,kCAAmB;AAAA,IACrB,QAAQ;AAAA,IAER;AAEA,UAAM,KAAK,KAAK,MAAM,GAAG,QAAQ,cAAc,EAAE,MAAM,KAAK,CAAC;AAE7D,SAAK,SAAS,EAAE;AAAA,EAClB;AAEA,QAAM,SAAsB,IAAI,oBAAO;AAAA,IACrC,KAAK;AAAA,IAEL,OAAO;AACL,aAAO;AAAA,QACL,QAAQ,OAAO,MAAM,cAAc;AApX3C;AAqXU,gBAAM,QAAO,UAAK,QAAL,mBAAU,SAAS;AAChC,gBAAM,QAAO,UAAK,QAAL,mBAAU,SAAS,KAAK;AAGrC,gBAAM,QAAQ,KAAK,UAAU,KAAK,UAAU,KAAK,MAAM,SAAS,KAAK,MAAM;AAC3E,gBAAM,UAAU,CAAC,KAAK,UAAU,KAAK;AACrC,gBAAM,UAAU,KAAK,UAAU,CAAC,KAAK;AACrC,gBAAM,UAAU,CAAC,WAAW,CAAC,WAAW,KAAK,UAAU,KAAK;AAE5D,gBAAM,cAAc,WAAY,SAAS;AACzC,gBAAM,eAAe,WAAW;AAChC,gBAAM,aAAa,WAAY,SAAS;AAGxC,cAAI,CAAC,eAAe,CAAC,gBAAgB,CAAC,YAAY;AAChD;AAAA,UACF;AAEA,gBAAM,QAAQ,cAAc,CAAC,cAAc,OAAO;AAClD,gBAAM,iBAAiB,KAAK,IAAI;AAAA,YAC9B,wBAAwB,MAAM,YAAY;AAAA,UAC5C;AAEA,kBAAQ;AAAA,YACN;AAAA,YACA,OAAO,MAAM;AAAA,YACb,OAAO,MAAM;AAAA,YACb,MAAM,MAAM;AAAA,YACZ,OAAO,CAAC;AAAA,YACR,SAAS,kBAAgB;AACvB,qBAAO,QAAQ;AAAA,gBACb;AAAA,gBACA,OAAO,MAAM;AAAA,gBACb,OAAO;AAAA,cACT,CAAC;AAAA,YACH;AAAA,YACA;AAAA,YACA,YAAY,cAAc,MAAM,cAAc;AAAA,UAChD;AAEA,cAAI,aAAa;AACf,uDAAU,kBAAV,kCAA0B;AAAA,UAC5B;AAEA,cAAI,cAAc;AAChB,uDAAU,mBAAV,kCAA2B;AAAA,UAC7B;AAEA,cAAI,gBAAgB,aAAa;AAC/B,kBAAM,QAAQ,MAAM,MAAM;AAAA,cACxB;AAAA,cACA,OAAO,MAAM;AAAA,YACf,CAAC;AAAA,UACH;AAEA,cAAI,YAAY;AACd,uDAAU,WAAV,kCAAmB;AAAA,UACrB;AAEA,cAAI,cAAc;AAChB,uDAAU,aAAV,kCAAqB;AAAA,UACvB;AAEA,cAAI,aAAa;AACf,uDAAU,YAAV,kCAAoB;AAAA,UACtB;AAAA,QACF;AAAA,QAEA,SAAS,MAAM;AAzbvB;AA0bU,cAAI,CAAC,OAAO;AACV;AAAA,UACF;AAEA,qDAAU,WAAV,kCAAmB;AAAA,QACrB;AAAA,MACF;AAAA,IACF;AAAA,IAEA,OAAO;AAAA;AAAA,MAEL,OAAO;AACL,cAAM,QAQF;AAAA,UACF,QAAQ;AAAA,UACR,OAAO;AAAA,YACL,MAAM;AAAA,YACN,IAAI;AAAA,UACN;AAAA,UACA,OAAO;AAAA,UACP,MAAM;AAAA,UACN,WAAW;AAAA,UACX,gBAAgB;AAAA,QAClB;AAEA,eAAO;AAAA,MACT;AAAA;AAAA,MAGA,MAAM,aAAa,MAAM,WAAW,OAAO;AACzC,cAAM,EAAE,WAAW,IAAI;AACvB,cAAM,EAAE,UAAU,IAAI,OAAO;AAC7B,cAAM,EAAE,UAAU,IAAI;AACtB,cAAM,EAAE,OAAO,KAAK,IAAI;AACxB,cAAM,OAAO,EAAE,GAAG,KAAK;AAMvB,cAAM,OAAO,YAAY,QAAQ,SAAS;AAC1C,YAAI,QAAQ,KAAK,MAAM;AACrB,eAAK,SAAS;AACd,eAAK,eAAe;AACpB,eAAK,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE;AAC9B,eAAK,QAAQ;AACb,eAAK,OAAO;AACZ,eAAK,iBAAiB,KAAK,SAAS,EAAE,GAAG,KAAK,MAAM,IAAI,KAAK;AAE7D,iBAAO;AAAA,QACT;AAEA,aAAK,YAAY;AAEjB,YAAI,YAAY,cAAc,KAAK,mBAAmB,MAAM;AAC1D,eAAK,iBAAiB;AAAA,YACpB,MAAM,YAAY,QAAQ,IAAI,KAAK,eAAe,IAAI;AAAA,YACtD,IAAI,YAAY,QAAQ,IAAI,KAAK,eAAe,EAAE;AAAA,UACpD;AAAA,QACF;AAKA,YAAI,eAAe,SAAS,OAAO,KAAK,YAAY;AAElD,eAAK,OAAO,KAAK,MAAM,QAAQ,OAAO,KAAK,MAAM,OAAO,CAAC,aAAa,CAAC,KAAK,WAAW;AACrF,iBAAK,SAAS;AAAA,UAChB;AAGA,gBAAM,QAAQA,qBAAoB;AAAA,YAChC;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA,WAAW,UAAU;AAAA,UACvB,CAAC;AACD,gBAAM,eAAe,MAAM,KAAK,MAAM,KAAK,OAAO,IAAI,UAAU,CAAC;AAGjE,cACE,SACA,MAAM;AAAA,YACJ;AAAA,YACA;AAAA,YACA,OAAO,MAAM;AAAA,YACb,UAAU,KAAK;AAAA,UACjB,CAAC,MACA,CAAC,cACA,WAAW;AAAA,YACT;AAAA,YACA,OAAO,MAAM;AAAA,YACb,OAAO,MAAM;AAAA,YACb,MAAM,MAAM;AAAA,YACZ;AAAA,UACF,CAAC,IACH;AACA,gBACE,KAAK,mBAAmB,QACxB,CAAC,oBAAoB;AAAA,cACnB;AAAA,cACA,gBAAgB,KAAK;AAAA,cACrB;AAAA,cACA;AAAA,YACF,CAAC,GACD;AACA,mBAAK,iBAAiB;AAAA,YACxB;AAEA,gBAAI,KAAK,mBAAmB,MAAM;AAChC,mBAAK,SAAS;AACd,mBAAK,eAAe,KAAK,eAAe,KAAK,eAAe;AAC5D,mBAAK,QAAQ,MAAM;AACnB,mBAAK,QAAQ,MAAM;AACnB,mBAAK,OAAO,MAAM;AAAA,YACpB,OAAO;AACL,mBAAK,SAAS;AAAA,YAChB;AAAA,UACF,OAAO;AACL,gBAAI,CAAC,OAAO;AACV,mBAAK,iBAAiB;AAAA,YACxB;AACA,iBAAK,SAAS;AAAA,UAChB;AAAA,QACF,OAAO;AACL,eAAK,SAAS;AAAA,QAChB;AAGA,YAAI,CAAC,KAAK,QAAQ;AAChB,eAAK,eAAe;AACpB,eAAK,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE;AAC9B,eAAK,QAAQ;AACb,eAAK,OAAO;AAAA,QACd;AAEA,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,OAAO;AAAA;AAAA,MAEL,cAAc,MAAM,OAAO;AAjlBjC;AAklBQ,cAAM,EAAE,QAAQ,MAAM,IAAI,OAAO,SAAS,KAAK,KAAK;AAEpD,YAAI,CAAC,QAAQ;AACX,iBAAO;AAAA,QACT;AAMA,YAAI,MAAM,QAAQ,YAAY,MAAM,QAAQ,OAAO;AACjD,gBAAM,QAAQ,OAAO,SAAS,KAAK,KAAK;AAIxC,qDAAU,cAAV,kCAAsB,EAAE,MAAM,OAAO,OAAO,MAAM,MAAM;AAGxD,uBAAa,MAAM,SAAS;AAE5B,iBAAO;AAAA,QACT;AAEA,cAAM,YAAU,0CAAU,cAAV,kCAAsB,EAAE,MAAM,OAAO,MAAM,OAAM;AACjE,eAAO;AAAA,MACT;AAAA;AAAA,MAGA,YAAY,OAAO;AACjB,cAAM,EAAE,QAAQ,OAAO,cAAc,MAAM,IAAI,OAAO,SAAS,KAAK;AAEpE,YAAI,CAAC,QAAQ;AACX,iBAAO;AAAA,QACT;AAEA,cAAM,UAAU,EAAC,+BAAO;AACxB,cAAM,aAAa,CAAC,eAAe;AAEnC,YAAI,SAAS;AACX,qBAAW,KAAK,oBAAoB;AAAA,QACtC;AAEA,eAAO,0BAAc,OAAO,MAAM,KAAK;AAAA,UACrC,uBAAW,OAAO,MAAM,MAAM,MAAM,IAAI;AAAA,YACtC,UAAU;AAAA,YACV,OAAO,WAAW,KAAK,GAAG;AAAA,YAC1B,sBAAsB;AAAA,YACtB,2BAA2B;AAAA,UAC7B,CAAC;AAAA,QACH,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF,CAAC;AAED,SAAO;AACT;AAOO,SAAS,eAAe,MAAkB,eAA0B,qBAAqB;AAC9F,QAAM,KAAK,KAAK,MAAM,GAAG,QAAQ,cAAc,EAAE,MAAM,KAAK,CAAC;AAC7D,OAAK,SAAS,EAAE;AAClB;;;AD5oBA,IAAO,gBAAQ;","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"]} |
+193
-2
@@ -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 }; |
+193
-2
@@ -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 }; |
+624
-270
| // 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"]} |
+10
-5
| { | ||
| "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() | ||
| }) | ||
| }) |
+93
-602
@@ -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 @@ |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Network access
Supply chain riskThis module accesses the network.
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
256678
82.94%20
53.85%4328
118.04%1
-50%3
50%3
50%10
Infinity%