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
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:
86
packages/suggestion/src/findSuggestionMatch.ts
Normal file
86
packages/suggestion/src/findSuggestionMatch.ts
Normal 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
|
||||
}
|
||||
129
packages/suggestion/src/helpers.ts
Normal file
129
packages/suggestion/src/helpers.ts
Normal 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)
|
||||
}
|
||||
8
packages/suggestion/src/index.ts
Normal file
8
packages/suggestion/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { exitSuggestion, Suggestion } from './suggestion.js'
|
||||
|
||||
export * from './findSuggestionMatch.js'
|
||||
export * from './suggestion.js'
|
||||
|
||||
export { exitSuggestion }
|
||||
|
||||
export default Suggestion
|
||||
89
packages/suggestion/src/plugin/async.ts
Normal file
89
packages/suggestion/src/plugin/async.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
204
packages/suggestion/src/plugin/floating-ui.ts
Normal file
204
packages/suggestion/src/plugin/floating-ui.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
packages/suggestion/src/plugin/props.ts
Normal file
94
packages/suggestion/src/plugin/props.ts
Normal 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,
|
||||
}),
|
||||
])
|
||||
},
|
||||
}
|
||||
}
|
||||
182
packages/suggestion/src/plugin/state.ts
Normal file
182
packages/suggestion/src/plugin/state.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
236
packages/suggestion/src/plugin/view.ts
Normal file
236
packages/suggestion/src/plugin/view.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
151
packages/suggestion/src/suggestion.ts
Normal file
151
packages/suggestion/src/suggestion.ts
Normal 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)
|
||||
}
|
||||
439
packages/suggestion/src/types.ts
Normal file
439
packages/suggestion/src/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user