snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import type { Range } from '@tiptap/core'
import { escapeForRegEx } from '@tiptap/core'
import type { ResolvedPos } from '@tiptap/pm/model'
export interface Trigger {
char: string
allowSpaces: boolean
allowToIncludeChar: boolean
allowedPrefixes: string[] | null
startOfLine: boolean
$position: ResolvedPos
}
export type SuggestionMatch = {
range: Range
query: string
text: string
} | null
export function findSuggestionMatch(config: Trigger): SuggestionMatch {
const {
char,
allowSpaces: allowSpacesOption,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
$position,
} = config
const allowSpaces = allowSpacesOption && !allowToIncludeChar
const escapedChar = escapeForRegEx(char)
const suffix = new RegExp(`\\s${escapedChar}$`)
const prefix = startOfLine ? '^' : ''
const finalEscapedChar = allowToIncludeChar ? '' : escapedChar
const regexp = allowSpaces
? new RegExp(`${prefix}${escapedChar}.*?(?=\\s${finalEscapedChar}|$)`, 'gm')
: new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${finalEscapedChar}]*`, 'gm')
const text = $position.nodeBefore?.isText && $position.nodeBefore.text
if (!text) {
return null
}
const textFrom = $position.pos - text.length
const match = Array.from(text.matchAll(regexp)).pop()
if (!match || match.input === undefined || match.index === undefined) {
return null
}
// JavaScript doesn't have lookbehinds. This hacks a check that first character
// is a space or the start of the line
const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)
const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\0]?$`).test(matchPrefix)
if (allowedPrefixes !== null && !matchPrefixIsAllowed) {
return null
}
// The absolute position of the match in the document
const from = textFrom + match.index
let to = from + match[0].length
// Edge case handling; if spaces are allowed and we're directly in between
// two triggers
if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {
match[0] += ' '
to += 1
}
// If the $position is located within the matched substring, return that range
if (from < $position.pos && to >= $position.pos) {
return {
range: {
from,
to,
},
query: match[0].slice(char.length),
text: match[0],
}
}
return null
}

View File

@@ -0,0 +1,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)
}

View File

@@ -0,0 +1,8 @@
import { exitSuggestion, Suggestion } from './suggestion.js'
export * from './findSuggestionMatch.js'
export * from './suggestion.js'
export { exitSuggestion }
export default Suggestion

View File

@@ -0,0 +1,89 @@
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,
}
}

View File

@@ -0,0 +1,204 @@
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()
}
}
}
}

View File

@@ -0,0 +1,94 @@
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,
}),
])
},
}
}

View File

@@ -0,0 +1,182 @@
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
},
}
}

View File

@@ -0,0 +1,236 @@
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)
},
}
}

View File

@@ -0,0 +1,151 @@
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 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'
export type {
SuggestionFloatingUiConfig,
SuggestionFloatingUiOptions,
SuggestionKeyDownProps,
SuggestionMount,
SuggestionMountOptions,
SuggestionOptions,
SuggestionPlacement,
SuggestionPositionData,
SuggestionProps,
} from './types.js'
export const SuggestionPluginKey = new PluginKey('suggestion')
type ShouldKeepDismissedProps = {
match: Exclude<SuggestionMatch, null>
dismissedRange: Range
state: EditorState
transaction: Transaction
}
/**
* This utility allows you to create suggestions.
* @see https://tiptap.dev/api/utilities/suggestion
*/
export function Suggestion<I = any, TSelected = any>({
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 = defaultFindSuggestionMatch,
shouldShow,
shouldResetDismissed,
}: SuggestionOptions<I, TSelected>) {
const renderer = render?.()
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar
const clientRectFor = (view: EditorView, decorationNode: Element | null) =>
clientRectForHelper(editor, view, decorationNode, pluginKey)
// 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,
})
}
const dispatchExit = (view: EditorView) =>
dispatchExitHelper({
view,
pluginKeyRef: pluginKey,
})
return new Plugin({
key: pluginKey,
view: () =>
createSuggestionView({
editor,
pluginKey,
items,
renderer,
minQueryLength,
debounce,
initialItems,
placement,
offset: offsetOption,
container,
flip,
floatingUi,
dismissOnOutsideClick,
command,
clientRectFor,
dispatchExit,
}),
state: createSuggestionState({
editor,
char,
effectiveAllowSpaces,
allowToIncludeChar,
allowedPrefixes,
startOfLine,
findSuggestionMatch,
allow,
shouldShow,
shouldKeepDismissed,
pluginKey,
}),
props: createSuggestionProps({
pluginKey,
decorationTag,
decorationClass,
decorationContent,
decorationEmptyClass,
renderer,
dispatchExit,
}),
})
}
/**
* Programmatically exit a suggestion plugin by dispatching a metadata-only
* transaction. This is the safe, recommended API to remove suggestion
* decorations without touching the document or causing mapping errors.
*/
export function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
view.dispatch(tr)
}

View File

@@ -0,0 +1,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
}