Skip to content

Commit 691c138

Browse files
committed
Fix @tiptap/suggestion plugin to work correctly with allowSpaces: true (see: ueberdosis#214 (comment))
1 parent cd98697 commit 691c138

2 files changed

Lines changed: 58 additions & 41 deletions

File tree

packages/suggestion/src/findSuggestionMatch.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,11 @@ export function findSuggestionMatch(config: Trigger): SuggestionMatch {
3030
? new RegExp(`${prefix}${escapedChar}.*?(?=\\s${finalEscapedChar}|$)`, 'gm')
3131
: new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${finalEscapedChar}]*`, 'gm')
3232

33-
const text = $position.nodeBefore?.isText && $position.nodeBefore.text
33+
const isTopLevelNode = $position.depth <= 0
34+
const textFrom = isTopLevelNode ? 0 : $position.before()
35+
const textTo = $position.pos
36+
const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0')
3437

35-
if (!text) {
36-
return null
37-
}
38-
39-
const textFrom = $position.pos - text.length
4038
const match = Array.from(text.matchAll(regexp)).pop()
4139

4240
if (!match || match.input === undefined || match.index === undefined) {
@@ -53,7 +51,7 @@ export function findSuggestionMatch(config: Trigger): SuggestionMatch {
5351
}
5452

5553
// The absolute position of the match in the document
56-
const from = textFrom + match.index
54+
const from = match.index + $position.start()
5755
let to = from + match[0].length
5856

5957
// Edge case handling; if spaces are allowed and we're directly in between

packages/suggestion/src/suggestion.ts

Lines changed: 53 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export function Suggestion<I = any, TSelected = any>({
278278
const next = this.key?.getState(view.state)
279279

280280
// See how the state changed
281-
const moved = prev.active && next.active && prev.range.from !== next.range.from
281+
const moved = prev.active && next.active && !!prev.range.from && prev.range.from !== next.range.from
282282
const started = !prev.active && next.active
283283
const stopped = prev.active && !next.active
284284
const changed = !started && !stopped && prev.query !== next.query
@@ -300,8 +300,18 @@ export function Suggestion<I = any, TSelected = any>({
300300
range: state.range,
301301
query: state.query,
302302
text: state.text,
303-
items: [],
303+
items: handleChange || handleStart
304+
? await items({
305+
editor,
306+
query: state.query,
307+
})
308+
: [],
304309
command: commandProps => {
310+
if (!commandProps) {
311+
dispatchExit(editor.view, pluginKey)
312+
return
313+
}
314+
305315
return command({
306316
editor,
307317
range: state.range,
@@ -312,31 +322,13 @@ export function Suggestion<I = any, TSelected = any>({
312322
clientRect: clientRectFor(view, decorationNode),
313323
}
314324

315-
if (handleStart) {
316-
renderer?.onBeforeStart?.(props)
317-
}
318-
319-
if (handleChange) {
320-
renderer?.onBeforeUpdate?.(props)
321-
}
322-
323-
if (handleChange || handleStart) {
324-
props.items = await items({
325-
editor,
326-
query: state.query,
327-
})
328-
}
329-
330325
if (handleExit) {
331-
renderer?.onExit?.(props)
332-
}
333-
334-
if (handleChange) {
335-
renderer?.onUpdate?.(props)
336-
}
337-
338-
if (handleStart) {
339-
renderer?.onStart?.(props)
326+
dispatchExit(view, pluginKey)
327+
return renderer?.onExit?.(props)
328+
} if (handleChange) {
329+
return renderer?.onUpdate?.(props)
330+
} if (handleStart) {
331+
return renderer?.onStart?.(props)
340332
}
341333
},
342334

@@ -355,13 +347,15 @@ export function Suggestion<I = any, TSelected = any>({
355347
init() {
356348
const state: {
357349
active: boolean
350+
key: null | string
358351
range: Range
359352
query: null | string
360353
text: null | string
361354
composing: boolean
362355
decorationId?: string | null
363356
} = {
364357
active: false,
358+
key: null,
365359
range: {
366360
from: 0,
367361
to: 0,
@@ -380,25 +374,40 @@ export function Suggestion<I = any, TSelected = any>({
380374
const { composing } = editor.view
381375
const { selection } = transaction
382376
const { empty, from } = selection
383-
const next = { ...prev }
377+
let next = { ...prev }
384378

385379
// If a transaction carries the exit meta for this plugin, immediately
386380
// deactivate the suggestion. This allows metadata-only transactions
387381
// (dispatched by escape or programmatic exit) to deterministically
388382
// clear decorations without changing the document.
389383
const meta = transaction.getMeta(pluginKey)
390-
if (meta && meta.exit) {
391-
next.active = false
392-
next.decorationId = null
393-
next.range = { from: 0, to: 0 }
394-
next.query = null
395-
next.text = null
384+
if (meta) {
385+
if (meta.exit) {
386+
next.active = false
387+
next.decorationId = null
388+
next.range = {from: 0, to: 0}
389+
next.query = null
390+
next.text = null
396391

397-
return next
392+
return next
393+
}
394+
395+
if (meta.state) {
396+
next = meta.state
397+
}
398398
}
399399

400400
next.composing = composing
401401

402+
// Don't advance with suggestion if not active and not the open character.
403+
if (!next.active && next.key !== char) {
404+
return next
405+
}
406+
if (next.active && next.key === char) {
407+
next.key = null
408+
return next
409+
}
410+
402411
// We can only be suggesting if the view is editable, and:
403412
// * there is no selection, or
404413
// * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
@@ -458,6 +467,16 @@ export function Suggestion<I = any, TSelected = any>({
458467
handleKeyDown(view, event) {
459468
const { active, range } = plugin.getState(view.state)
460469

470+
// Set state and handle start
471+
if (!active && event.key === char) {
472+
const state = plugin.getState(view.state)
473+
const updatedState = { ...state }
474+
updatedState.active = true
475+
updatedState.key = event.key
476+
view.dispatch(view.state.tr.setMeta(pluginKey, { state: updatedState }))
477+
return
478+
}
479+
461480
if (!active) {
462481
return false
463482
}

0 commit comments

Comments
 (0)