@tiptap/suggestion
Advanced tools
Comparing version
@@ -187,3 +187,9 @@ import { Range, Editor } from '@tiptap/core'; | ||
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, }: SuggestionOptions<I, TSelected>): Plugin<any>; | ||
/** | ||
* 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. | ||
*/ | ||
declare function exitSuggestion(view: EditorView, pluginKeyRef?: PluginKey): void; | ||
export { Suggestion, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionOptions, SuggestionPluginKey, type SuggestionProps, type Trigger, Suggestion as default, findSuggestionMatch }; | ||
export { Suggestion, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionOptions, SuggestionPluginKey, type SuggestionProps, type Trigger, Suggestion as default, exitSuggestion, findSuggestionMatch }; |
@@ -50,2 +50,3 @@ // src/suggestion.ts | ||
// src/suggestion.ts | ||
var clickHandlerMap = /* @__PURE__ */ new WeakMap(); | ||
var SuggestionPluginKey = new PluginKey("suggestion"); | ||
@@ -72,5 +73,75 @@ function Suggestion({ | ||
const renderer = render == null ? void 0 : render(); | ||
const clientRectFor = (view, decorationNode) => { | ||
if (!decorationNode) { | ||
return null; | ||
} | ||
return () => { | ||
const state = pluginKey.getState(editor.state); | ||
const decorationId = state == null ? void 0 : state.decorationId; | ||
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`); | ||
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null; | ||
}; | ||
}; | ||
function dispatchExit(view, pluginKeyRef) { | ||
var _a; | ||
try { | ||
const state = pluginKey.getState(view.state); | ||
const decorationNode = (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null; | ||
const exitProps = { | ||
// @ts-ignore editor is available in closure | ||
editor, | ||
range: (state == null ? void 0 : state.range) || { from: 0, to: 0 }, | ||
query: (state == null ? void 0 : state.query) || null, | ||
text: (state == null ? void 0 : state.text) || null, | ||
items: [], | ||
command: (commandProps) => { | ||
return command({ editor, range: (state == null ? void 0 : state.range) || { from: 0, to: 0 }, props: commandProps }); | ||
}, | ||
decorationNode, | ||
clientRect: clientRectFor(view, decorationNode) | ||
}; | ||
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, exitProps); | ||
} catch { | ||
} | ||
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true }); | ||
view.dispatch(tr); | ||
} | ||
const plugin = new Plugin({ | ||
key: pluginKey, | ||
view() { | ||
view(editorView) { | ||
const ensureClickHandler = (view) => { | ||
if (clickHandlerMap.has(view)) { | ||
return; | ||
} | ||
const handler = (event) => { | ||
if (!props) { | ||
return; | ||
} | ||
const decorationNode = props.decorationNode; | ||
const target = event.target; | ||
if (!decorationNode) { | ||
return; | ||
} | ||
if (target && decorationNode.contains(target)) { | ||
return; | ||
} | ||
if (target && view.dom.contains(target)) { | ||
return; | ||
} | ||
if (target && target.closest && target.closest(".react-renderer")) { | ||
return; | ||
} | ||
dispatchExit(view, pluginKey); | ||
}; | ||
document.addEventListener("mousedown", handler, true); | ||
clickHandlerMap.set(view, handler); | ||
}; | ||
const removeClickHandler = (view) => { | ||
const handler = clickHandlerMap.get(view); | ||
if (!handler) { | ||
return; | ||
} | ||
document.removeEventListener("mousedown", handler, true); | ||
clickHandlerMap.delete(view); | ||
}; | ||
return { | ||
@@ -107,10 +178,3 @@ update: async (view, prevState) => { | ||
decorationNode, | ||
// virtual node for positioning | ||
// this can be used for building popups without a DOM node | ||
clientRect: decorationNode ? () => { | ||
var _a2; | ||
const { decorationId } = (_a2 = this.key) == null ? void 0 : _a2.getState(editor.state); | ||
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`); | ||
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null; | ||
} : null | ||
clientRect: clientRectFor(view, decorationNode) | ||
}; | ||
@@ -138,5 +202,11 @@ if (handleStart) { | ||
} | ||
if (next.active) { | ||
ensureClickHandler(view); | ||
} else { | ||
removeClickHandler(editorView); | ||
} | ||
}, | ||
destroy: () => { | ||
var _a; | ||
removeClickHandler(editorView); | ||
if (!props) { | ||
@@ -171,2 +241,11 @@ return; | ||
const next = { ...prev }; | ||
const meta = transaction.getMeta(pluginKey); | ||
if (meta && meta.exit) { | ||
next.active = false; | ||
next.decorationId = null; | ||
next.range = { from: 0, to: 0 }; | ||
next.query = null; | ||
next.text = null; | ||
return next; | ||
} | ||
next.composing = composing; | ||
@@ -215,3 +294,3 @@ if (isEditable && (empty || editor.view.composing)) { | ||
handleKeyDown(view, event) { | ||
var _a; | ||
var _a, _b, _c; | ||
const { active, range } = plugin.getState(view.state); | ||
@@ -221,3 +300,29 @@ if (!active) { | ||
} | ||
return ((_a = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _a.call(renderer, { view, event, range })) || false; | ||
if (event.key === "Escape" || event.key === "Esc") { | ||
const state = plugin.getState(view.state); | ||
const cachedNode = (_a = props == null ? void 0 : props.decorationNode) != null ? _a : null; | ||
const decorationNode = cachedNode != null ? cachedNode : (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null; | ||
const exitProps = { | ||
editor, | ||
range: state.range, | ||
query: state.query, | ||
text: state.text, | ||
items: [], | ||
command: (commandProps) => { | ||
return command({ editor, range: state.range, props: commandProps }); | ||
}, | ||
decorationNode, | ||
// If we have a cached decoration node, use it for the clientRect | ||
// to avoid another DOM lookup. If not, leave clientRect null and | ||
// let consumer decide if they want to query. | ||
clientRect: decorationNode ? () => { | ||
return decorationNode.getBoundingClientRect() || null; | ||
} : null | ||
}; | ||
(_b = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _b.call(renderer, exitProps); | ||
dispatchExit(view, pluginKey); | ||
return true; | ||
} | ||
const handled = ((_c = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _c.call(renderer, { view, event, range })) || false; | ||
return handled; | ||
}, | ||
@@ -248,2 +353,6 @@ // Setup decorator on the currently active suggestion. | ||
} | ||
function exitSuggestion(view, pluginKeyRef = SuggestionPluginKey) { | ||
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true }); | ||
view.dispatch(tr); | ||
} | ||
@@ -256,4 +365,5 @@ // src/index.ts | ||
index_default as default, | ||
exitSuggestion, | ||
findSuggestionMatch | ||
}; | ||
//# sourceMappingURL=index.js.map |
{ | ||
"name": "@tiptap/suggestion", | ||
"description": "suggestion plugin for tiptap", | ||
"version": "3.2.2", | ||
"version": "3.3.0", | ||
"homepage": "https://tiptap.dev", | ||
@@ -34,8 +34,8 @@ "keywords": [ | ||
"devDependencies": { | ||
"@tiptap/core": "^3.2.2", | ||
"@tiptap/pm": "^3.2.2" | ||
"@tiptap/core": "^3.3.0", | ||
"@tiptap/pm": "^3.3.0" | ||
}, | ||
"peerDependencies": { | ||
"@tiptap/core": "^3.2.2", | ||
"@tiptap/pm": "^3.2.2" | ||
"@tiptap/core": "^3.3.0", | ||
"@tiptap/pm": "^3.3.0" | ||
}, | ||
@@ -42,0 +42,0 @@ "repository": { |
@@ -1,2 +0,2 @@ | ||
import { Suggestion } from './suggestion.js' | ||
import { exitSuggestion, Suggestion } from './suggestion.js' | ||
@@ -6,2 +6,4 @@ export * from './findSuggestionMatch.js' | ||
export { exitSuggestion } | ||
export default Suggestion |
@@ -9,2 +9,7 @@ import type { Editor, Range } from '@tiptap/core' | ||
// Track document click handlers per EditorView instance to avoid accidental | ||
// leaks when multiple editors are created/destroyed. WeakMap ensures handlers | ||
// don't keep views alive. | ||
const clickHandlerMap: WeakMap<EditorView, (event: MouseEvent) => void> = new WeakMap() | ||
export interface SuggestionOptions<I = any, TSelected = any> { | ||
@@ -209,6 +214,105 @@ /** | ||
// Helper to create a clientRect callback for a given decoration node. | ||
// Returns null when no decoration node is present. Uses the pluginKey's | ||
// state to resolve the current decoration node on demand, avoiding a | ||
// duplicated implementation in multiple places. | ||
const clientRectFor = (view: EditorView, decorationNode: Element | null) => { | ||
if (!decorationNode) { | ||
return null | ||
} | ||
return () => { | ||
const state = pluginKey.getState(editor.state) | ||
const decorationId = state?.decorationId | ||
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`) | ||
return currentDecorationNode?.getBoundingClientRect() || null | ||
} | ||
} | ||
// small helper used internally by the view to dispatch an exit | ||
function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) { | ||
try { | ||
// Try to call renderer.onExit so consumer renderers (for example the | ||
// demos' ReactRenderer) can clean up and unmount immediately. This | ||
// covers paths where we only dispatch a metadata transaction (like | ||
// click-outside) and ensures we don't leak DOM nodes / React roots. | ||
const state = pluginKey.getState(view.state) | ||
const decorationNode = state?.decorationId | ||
? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) | ||
: null | ||
const exitProps: SuggestionProps = { | ||
// @ts-ignore editor is available in closure | ||
editor, | ||
range: state?.range || { from: 0, to: 0 }, | ||
query: state?.query || null, | ||
text: state?.text || null, | ||
items: [], | ||
command: commandProps => { | ||
return command({ editor, range: state?.range || { from: 0, to: 0 }, props: commandProps as any }) | ||
}, | ||
decorationNode, | ||
clientRect: clientRectFor(view, decorationNode), | ||
} | ||
renderer?.onExit?.(exitProps) | ||
} catch { | ||
// ignore errors from consumer renderers | ||
} | ||
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true }) | ||
// Dispatch a metadata-only transaction to signal the plugin to exit | ||
view.dispatch(tr) | ||
} | ||
const plugin: Plugin<any> = new Plugin({ | ||
key: pluginKey, | ||
view() { | ||
view(editorView: EditorView) { | ||
const ensureClickHandler = (view: EditorView) => { | ||
if (clickHandlerMap.has(view)) { | ||
return | ||
} | ||
const handler = (event: MouseEvent) => { | ||
if (!props) { | ||
return | ||
} | ||
const decorationNode = props.decorationNode | ||
const target = event.target as Element | null | ||
if (!decorationNode) { | ||
return | ||
} | ||
if (target && decorationNode.contains(target)) { | ||
return | ||
} | ||
if (target && view.dom.contains(target)) { | ||
return | ||
} | ||
if (target && target.closest && target.closest('.react-renderer')) { | ||
return | ||
} | ||
dispatchExit(view, pluginKey) | ||
} | ||
document.addEventListener('mousedown', handler, true) | ||
clickHandlerMap.set(view, handler) | ||
} | ||
const removeClickHandler = (view: EditorView) => { | ||
const handler = clickHandlerMap.get(view) | ||
if (!handler) { | ||
return | ||
} | ||
document.removeEventListener('mousedown', handler, true) | ||
clickHandlerMap.delete(view) | ||
} | ||
return { | ||
@@ -251,13 +355,3 @@ update: async (view, prevState) => { | ||
decorationNode, | ||
// virtual node for positioning | ||
// this can be used for building popups without a DOM node | ||
clientRect: decorationNode | ||
? () => { | ||
// because of `items` can be asynchrounous we’ll search for the current decoration node | ||
const { decorationId } = this.key?.getState(editor.state) // eslint-disable-line | ||
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`) | ||
return currentDecorationNode?.getBoundingClientRect() || null | ||
} | ||
: null, | ||
clientRect: clientRectFor(view, decorationNode), | ||
} | ||
@@ -291,5 +385,14 @@ | ||
} | ||
// Install / remove click handler depending on suggestion active state | ||
if (next.active) { | ||
ensureClickHandler(view) | ||
} else { | ||
removeClickHandler(editorView) | ||
} | ||
}, | ||
destroy: () => { | ||
removeClickHandler(editorView) | ||
if (!props) { | ||
@@ -336,2 +439,17 @@ return | ||
// 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 | ||
return next | ||
} | ||
next.composing = composing | ||
@@ -402,3 +520,43 @@ | ||
return renderer?.onKeyDown?.({ view, event, range }) || false | ||
// If Escape is pressed, call onExit and dispatch a metadata-only | ||
// transaction to unset the suggestion state. This provides a safe | ||
// and deterministic way to exit the suggestion without altering the | ||
// document (avoids transaction mapping/mismatch issues). | ||
if (event.key === 'Escape' || event.key === 'Esc') { | ||
const state = plugin.getState(view.state) | ||
const cachedNode = props?.decorationNode ?? null | ||
const decorationNode = | ||
cachedNode ?? | ||
(state?.decorationId ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null) | ||
const exitProps: SuggestionProps = { | ||
editor, | ||
range: state.range, | ||
query: state.query, | ||
text: state.text, | ||
items: [], | ||
command: commandProps => { | ||
return command({ editor, range: state.range, props: commandProps as any }) | ||
}, | ||
decorationNode, | ||
// If we have a cached decoration node, use it for the clientRect | ||
// to avoid another DOM lookup. If not, leave clientRect null and | ||
// let consumer decide if they want to query. | ||
clientRect: decorationNode | ||
? () => { | ||
return decorationNode.getBoundingClientRect() || null | ||
} | ||
: null, | ||
} | ||
renderer?.onExit?.(exitProps) | ||
// dispatch metadata-only transaction to unset the plugin state | ||
dispatchExit(view, pluginKey) | ||
return true | ||
} | ||
const handled = renderer?.onKeyDown?.({ view, event, range }) || false | ||
return handled | ||
}, | ||
@@ -435,1 +593,11 @@ | ||
} | ||
/** | ||
* 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) | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
120291
35.98%1509
32.37%