@tiptap/suggestion 3.26.1 → 3.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +619 -270
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +193 -2
- package/dist/index.d.ts +193 -2
- package/dist/index.js +624 -270
- package/dist/index.js.map +1 -1
- package/package.json +10 -5
- package/src/__tests__/suggestion.test.ts +837 -0
- package/src/helpers.ts +129 -0
- package/src/plugin/async.ts +89 -0
- package/src/plugin/floating-ui.ts +204 -0
- package/src/plugin/props.ts +94 -0
- package/src/plugin/state.ts +182 -0
- package/src/plugin/view.ts +236 -0
- package/src/suggestion.ts +97 -606
- package/src/types.ts +439 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Middleware } from '@floating-ui/dom'
|
|
1
2
|
import { Editor, Extension } from '@tiptap/core'
|
|
2
3
|
import StarterKit from '@tiptap/starter-kit'
|
|
3
4
|
import { describe, expect, it, vi } from 'vitest'
|
|
@@ -409,3 +410,839 @@ describe('suggestion dismissal', () => {
|
|
|
409
410
|
editor.destroy()
|
|
410
411
|
})
|
|
411
412
|
})
|
|
413
|
+
|
|
414
|
+
describe('suggestion minQueryLength', () => {
|
|
415
|
+
it('should not call items when query is shorter than minQueryLength', async () => {
|
|
416
|
+
const items = vi.fn().mockReturnValue([])
|
|
417
|
+
const onStart = vi.fn()
|
|
418
|
+
const onUpdate = vi.fn()
|
|
419
|
+
const onExit = vi.fn()
|
|
420
|
+
|
|
421
|
+
const MentionExtension = Extension.create({
|
|
422
|
+
name: 'mention-min-query',
|
|
423
|
+
addProseMirrorPlugins() {
|
|
424
|
+
return [
|
|
425
|
+
Suggestion({
|
|
426
|
+
editor: this.editor,
|
|
427
|
+
char: '@',
|
|
428
|
+
minQueryLength: 2,
|
|
429
|
+
items,
|
|
430
|
+
render: () => ({ onStart, onUpdate, onExit }),
|
|
431
|
+
}),
|
|
432
|
+
]
|
|
433
|
+
},
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
const editor = new Editor({
|
|
437
|
+
extensions: [StarterKit, MentionExtension],
|
|
438
|
+
content: '<p></p>',
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// Type @a — query "a" is too short (length 1 < 2)
|
|
442
|
+
editor.chain().insertContent('@a').run()
|
|
443
|
+
await Promise.resolve()
|
|
444
|
+
|
|
445
|
+
expect(onStart).toHaveBeenCalledTimes(1)
|
|
446
|
+
// items should not have been called because query.length < minQueryLength
|
|
447
|
+
expect(items).not.toHaveBeenCalled()
|
|
448
|
+
// The props passed to onStart should have items: []
|
|
449
|
+
expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ items: [] }))
|
|
450
|
+
|
|
451
|
+
// Continue typing to reach minQueryLength
|
|
452
|
+
editor.chain().insertContent('b').run()
|
|
453
|
+
await Promise.resolve()
|
|
454
|
+
|
|
455
|
+
// items should be called now with query 'ab'
|
|
456
|
+
expect(items).toHaveBeenCalledWith(
|
|
457
|
+
expect.objectContaining({
|
|
458
|
+
editor: expect.any(Object),
|
|
459
|
+
query: 'ab',
|
|
460
|
+
}),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
editor.destroy()
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
describe('suggestion initialItems', () => {
|
|
468
|
+
it('should pass initialItems to onBeforeStart/onBeforeUpdate and resolved items to onStart/onUpdate', async () => {
|
|
469
|
+
const initialItems = [{ id: 1, label: 'Popular' }]
|
|
470
|
+
const resolvedItems = [{ id: 2, label: 'Filtered' }]
|
|
471
|
+
const items = vi.fn().mockResolvedValue(resolvedItems)
|
|
472
|
+
const onBeforeStart = vi.fn()
|
|
473
|
+
const onBeforeUpdate = vi.fn()
|
|
474
|
+
const onStart = vi.fn()
|
|
475
|
+
const onUpdate = vi.fn()
|
|
476
|
+
const onExit = vi.fn()
|
|
477
|
+
|
|
478
|
+
const MentionExtension = Extension.create({
|
|
479
|
+
name: 'mention-initial-items',
|
|
480
|
+
addProseMirrorPlugins() {
|
|
481
|
+
return [
|
|
482
|
+
Suggestion({
|
|
483
|
+
editor: this.editor,
|
|
484
|
+
char: '@',
|
|
485
|
+
initialItems,
|
|
486
|
+
items,
|
|
487
|
+
render: () => ({ onBeforeStart, onBeforeUpdate, onStart, onUpdate, onExit }),
|
|
488
|
+
}),
|
|
489
|
+
]
|
|
490
|
+
},
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
const editor = new Editor({
|
|
494
|
+
extensions: [StarterKit, MentionExtension],
|
|
495
|
+
content: '<p></p>',
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
// Type @ to start suggestion
|
|
499
|
+
editor.chain().insertContent('@').run()
|
|
500
|
+
await Promise.resolve()
|
|
501
|
+
await Promise.resolve()
|
|
502
|
+
|
|
503
|
+
// onBeforeStart should receive initialItems
|
|
504
|
+
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ items: initialItems }))
|
|
505
|
+
// onStart mounts immediately with the initial items while loading
|
|
506
|
+
expect(onStart).toHaveBeenLastCalledWith(
|
|
507
|
+
expect.objectContaining({ items: initialItems, loading: true }),
|
|
508
|
+
)
|
|
509
|
+
// onUpdate receives the async-resolved items
|
|
510
|
+
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ items: resolvedItems }))
|
|
511
|
+
// items() should still have been called
|
|
512
|
+
expect(items).toHaveBeenCalledWith(
|
|
513
|
+
expect.objectContaining({
|
|
514
|
+
editor: expect.any(Object),
|
|
515
|
+
query: '',
|
|
516
|
+
}),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
// Reset mocks for the update phase
|
|
520
|
+
items.mockClear()
|
|
521
|
+
onBeforeUpdate.mockClear()
|
|
522
|
+
onUpdate.mockClear()
|
|
523
|
+
|
|
524
|
+
// Type another character to trigger an update
|
|
525
|
+
editor.chain().insertContent('a').run()
|
|
526
|
+
await Promise.resolve()
|
|
527
|
+
await Promise.resolve()
|
|
528
|
+
|
|
529
|
+
// onBeforeUpdate should also receive initialItems
|
|
530
|
+
expect(onBeforeUpdate).toHaveBeenCalledWith(expect.objectContaining({ items: initialItems }))
|
|
531
|
+
// onUpdate should receive the async-resolved items
|
|
532
|
+
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ items: resolvedItems }))
|
|
533
|
+
expect(items).toHaveBeenCalledWith(
|
|
534
|
+
expect.objectContaining({
|
|
535
|
+
editor: expect.any(Object),
|
|
536
|
+
query: 'a',
|
|
537
|
+
}),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
editor.destroy()
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
describe('suggestion loading state', () => {
|
|
545
|
+
it('should set loading to true in before callbacks and false after when items() is called', async () => {
|
|
546
|
+
const items = vi.fn().mockResolvedValue([])
|
|
547
|
+
const onBeforeStart = vi.fn()
|
|
548
|
+
const onBeforeUpdate = vi.fn()
|
|
549
|
+
const onStart = vi.fn()
|
|
550
|
+
const onUpdate = vi.fn()
|
|
551
|
+
const onExit = vi.fn()
|
|
552
|
+
|
|
553
|
+
const MentionExtension = Extension.create({
|
|
554
|
+
name: 'mention-loading',
|
|
555
|
+
addProseMirrorPlugins() {
|
|
556
|
+
return [
|
|
557
|
+
Suggestion({
|
|
558
|
+
editor: this.editor,
|
|
559
|
+
char: '@',
|
|
560
|
+
items,
|
|
561
|
+
render: () => ({ onBeforeStart, onBeforeUpdate, onStart, onUpdate, onExit }),
|
|
562
|
+
}),
|
|
563
|
+
]
|
|
564
|
+
},
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
const editor = new Editor({
|
|
568
|
+
extensions: [StarterKit, MentionExtension],
|
|
569
|
+
content: '<p></p>',
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
// Type @ to start suggestion — triggers async items()
|
|
573
|
+
editor.chain().insertContent('@').run()
|
|
574
|
+
await Promise.resolve()
|
|
575
|
+
await Promise.resolve()
|
|
576
|
+
|
|
577
|
+
// onBeforeStart fires before items() resolves → loading should be true
|
|
578
|
+
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
|
|
579
|
+
// onStart fires immediately with loading enabled
|
|
580
|
+
expect(onStart).toHaveBeenLastCalledWith(expect.objectContaining({ loading: true }))
|
|
581
|
+
// onUpdate fires after items() resolves → loading should be false
|
|
582
|
+
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
|
|
583
|
+
|
|
584
|
+
// Type another char to trigger an update
|
|
585
|
+
editor.chain().insertContent('a').run()
|
|
586
|
+
await Promise.resolve()
|
|
587
|
+
await Promise.resolve()
|
|
588
|
+
|
|
589
|
+
// onBeforeUpdate fires before items() resolves → loading should be true
|
|
590
|
+
expect(onBeforeUpdate).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
|
|
591
|
+
// onUpdate fires after items() resolves → loading should be false
|
|
592
|
+
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
|
|
593
|
+
|
|
594
|
+
editor.destroy()
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('should recover when items() rejects', async () => {
|
|
598
|
+
const items = vi.fn().mockRejectedValue(new Error('boom'))
|
|
599
|
+
const onBeforeStart = vi.fn()
|
|
600
|
+
const onUpdate = vi.fn()
|
|
601
|
+
const onStart = vi.fn()
|
|
602
|
+
|
|
603
|
+
const MentionExtension = Extension.create({
|
|
604
|
+
name: 'mention-loading-rejects',
|
|
605
|
+
addProseMirrorPlugins() {
|
|
606
|
+
return [
|
|
607
|
+
Suggestion({
|
|
608
|
+
editor: this.editor,
|
|
609
|
+
char: '@',
|
|
610
|
+
items,
|
|
611
|
+
render: () => ({ onBeforeStart, onStart, onUpdate }),
|
|
612
|
+
}),
|
|
613
|
+
]
|
|
614
|
+
},
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
const editor = new Editor({
|
|
618
|
+
extensions: [StarterKit, MentionExtension],
|
|
619
|
+
content: '<p></p>',
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
editor.chain().insertContent('@').run()
|
|
623
|
+
await Promise.resolve()
|
|
624
|
+
await Promise.resolve()
|
|
625
|
+
|
|
626
|
+
expect(items).toHaveBeenCalledTimes(1)
|
|
627
|
+
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
|
|
628
|
+
expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
|
|
629
|
+
|
|
630
|
+
editor.destroy()
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('should set loading to false in all callbacks when minQueryLength blocks items()', async () => {
|
|
634
|
+
const items = vi.fn().mockResolvedValue([])
|
|
635
|
+
const onBeforeStart = vi.fn()
|
|
636
|
+
const onStart = vi.fn()
|
|
637
|
+
|
|
638
|
+
const MentionExtension = Extension.create({
|
|
639
|
+
name: 'mention-loading-blocked',
|
|
640
|
+
addProseMirrorPlugins() {
|
|
641
|
+
return [
|
|
642
|
+
Suggestion({
|
|
643
|
+
editor: this.editor,
|
|
644
|
+
char: '@',
|
|
645
|
+
minQueryLength: 3,
|
|
646
|
+
items,
|
|
647
|
+
render: () => ({ onBeforeStart, onStart }),
|
|
648
|
+
}),
|
|
649
|
+
]
|
|
650
|
+
},
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
const editor = new Editor({
|
|
654
|
+
extensions: [StarterKit, MentionExtension],
|
|
655
|
+
content: '<p></p>',
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
// Type @a — query "a" is too short, items() won't be called
|
|
659
|
+
editor.chain().insertContent('@a').run()
|
|
660
|
+
await Promise.resolve()
|
|
661
|
+
|
|
662
|
+
// No async call happens → loading should be false
|
|
663
|
+
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: false }))
|
|
664
|
+
expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ loading: false }))
|
|
665
|
+
|
|
666
|
+
editor.destroy()
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
describe('suggestion AbortSignal', () => {
|
|
671
|
+
it('should pass signal to items() and abort previous signal on new query', async () => {
|
|
672
|
+
const signals: AbortSignal[] = []
|
|
673
|
+
let resolveFirst: (value: unknown) => void = () => {}
|
|
674
|
+
|
|
675
|
+
const items = vi.fn().mockImplementation(({ signal }) => {
|
|
676
|
+
signals.push(signal)
|
|
677
|
+
// First call returns a promise that we control
|
|
678
|
+
if (signals.length === 1) {
|
|
679
|
+
return new Promise(resolve => {
|
|
680
|
+
resolveFirst = resolve
|
|
681
|
+
})
|
|
682
|
+
}
|
|
683
|
+
// Subsequent calls resolve immediately
|
|
684
|
+
return []
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
const onStart = vi.fn()
|
|
688
|
+
const onUpdate = vi.fn()
|
|
689
|
+
|
|
690
|
+
const MentionExtension = Extension.create({
|
|
691
|
+
name: 'mention-abort',
|
|
692
|
+
addProseMirrorPlugins() {
|
|
693
|
+
return [
|
|
694
|
+
Suggestion({
|
|
695
|
+
editor: this.editor,
|
|
696
|
+
char: '@',
|
|
697
|
+
items,
|
|
698
|
+
render: () => ({ onStart, onUpdate }),
|
|
699
|
+
}),
|
|
700
|
+
]
|
|
701
|
+
},
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
const editor = new Editor({
|
|
705
|
+
extensions: [StarterKit, MentionExtension],
|
|
706
|
+
content: '<p></p>',
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
// Type @ to start suggestion — first items() call starts but doesn't resolve
|
|
710
|
+
editor.chain().insertContent('@').run()
|
|
711
|
+
await Promise.resolve()
|
|
712
|
+
|
|
713
|
+
expect(items).toHaveBeenCalledTimes(1)
|
|
714
|
+
expect(signals[0].aborted).toBe(false)
|
|
715
|
+
|
|
716
|
+
// Type a — triggers a second items() while first is still in-flight
|
|
717
|
+
editor.chain().insertContent('a').run()
|
|
718
|
+
await Promise.resolve()
|
|
719
|
+
|
|
720
|
+
// items() should have been called a second time
|
|
721
|
+
expect(items).toHaveBeenCalledTimes(2)
|
|
722
|
+
// The first signal should be aborted
|
|
723
|
+
expect(signals[0].aborted).toBe(true)
|
|
724
|
+
// The second signal should be fresh
|
|
725
|
+
expect(signals[1].aborted).toBe(false)
|
|
726
|
+
|
|
727
|
+
// Clean up the hanging promise
|
|
728
|
+
resolveFirst([])
|
|
729
|
+
await Promise.resolve()
|
|
730
|
+
|
|
731
|
+
editor.destroy()
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
it('should not emit stale callbacks after a request is superseded', async () => {
|
|
735
|
+
const signals: AbortSignal[] = []
|
|
736
|
+
let resolveFirst: (value: unknown) => void = () => {}
|
|
737
|
+
|
|
738
|
+
const items = vi.fn().mockImplementation(({ signal }) => {
|
|
739
|
+
signals.push(signal)
|
|
740
|
+
|
|
741
|
+
if (signals.length === 1) {
|
|
742
|
+
return new Promise(resolve => {
|
|
743
|
+
resolveFirst = resolve
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return []
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
const onStart = vi.fn()
|
|
751
|
+
const onUpdate = vi.fn()
|
|
752
|
+
|
|
753
|
+
const MentionExtension = Extension.create({
|
|
754
|
+
name: 'mention-abort-stale-callbacks',
|
|
755
|
+
addProseMirrorPlugins() {
|
|
756
|
+
return [
|
|
757
|
+
Suggestion({
|
|
758
|
+
editor: this.editor,
|
|
759
|
+
char: '@',
|
|
760
|
+
items,
|
|
761
|
+
render: () => ({ onStart, onUpdate }),
|
|
762
|
+
}),
|
|
763
|
+
]
|
|
764
|
+
},
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
const editor = new Editor({
|
|
768
|
+
extensions: [StarterKit, MentionExtension],
|
|
769
|
+
content: '<p></p>',
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
editor.chain().insertContent('@').run()
|
|
773
|
+
await Promise.resolve()
|
|
774
|
+
|
|
775
|
+
editor.chain().insertContent('a').run()
|
|
776
|
+
await Promise.resolve()
|
|
777
|
+
await Promise.resolve()
|
|
778
|
+
|
|
779
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
780
|
+
const updateCallsBefore = onUpdate.mock.calls.length
|
|
781
|
+
|
|
782
|
+
resolveFirst([])
|
|
783
|
+
await Promise.resolve()
|
|
784
|
+
await Promise.resolve()
|
|
785
|
+
|
|
786
|
+
expect(onStart.mock.calls.length).toBe(startCallsBefore)
|
|
787
|
+
expect(onUpdate.mock.calls.length).toBe(updateCallsBefore)
|
|
788
|
+
|
|
789
|
+
editor.destroy()
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
it('should pass signal as a property in the items callback props', async () => {
|
|
793
|
+
const items = vi.fn().mockImplementation(({ signal }) => {
|
|
794
|
+
expect(signal).toBeInstanceOf(AbortSignal)
|
|
795
|
+
return []
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
const MentionExtension = Extension.create({
|
|
799
|
+
name: 'mention-abort-prop',
|
|
800
|
+
addProseMirrorPlugins() {
|
|
801
|
+
return [
|
|
802
|
+
Suggestion({
|
|
803
|
+
editor: this.editor,
|
|
804
|
+
char: '@',
|
|
805
|
+
items,
|
|
806
|
+
}),
|
|
807
|
+
]
|
|
808
|
+
},
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
const editor = new Editor({
|
|
812
|
+
extensions: [StarterKit, MentionExtension],
|
|
813
|
+
content: '<p></p>',
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
editor.chain().insertContent('@').run()
|
|
817
|
+
await Promise.resolve()
|
|
818
|
+
|
|
819
|
+
expect(items).toHaveBeenCalled()
|
|
820
|
+
// The callback assertion already checked signal is an AbortSignal
|
|
821
|
+
|
|
822
|
+
editor.destroy()
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
describe('suggestion debounce', () => {
|
|
827
|
+
it('should delay the items() call by the configured debounce time', async () => {
|
|
828
|
+
vi.useFakeTimers()
|
|
829
|
+
|
|
830
|
+
const items = vi.fn().mockResolvedValue([])
|
|
831
|
+
const onBeforeStart = vi.fn()
|
|
832
|
+
const onStart = vi.fn()
|
|
833
|
+
|
|
834
|
+
const MentionExtension = Extension.create({
|
|
835
|
+
name: 'mention-debounce',
|
|
836
|
+
addProseMirrorPlugins() {
|
|
837
|
+
return [
|
|
838
|
+
Suggestion({
|
|
839
|
+
editor: this.editor,
|
|
840
|
+
char: '@',
|
|
841
|
+
debounce: 100,
|
|
842
|
+
items,
|
|
843
|
+
render: () => ({ onBeforeStart, onStart }),
|
|
844
|
+
}),
|
|
845
|
+
]
|
|
846
|
+
},
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
const editor = new Editor({
|
|
850
|
+
extensions: [StarterKit, MentionExtension],
|
|
851
|
+
content: '<p></p>',
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
// Type @ to trigger suggestion
|
|
855
|
+
editor.chain().insertContent('@').run()
|
|
856
|
+
|
|
857
|
+
// items() should not have been called yet (debounce pending)
|
|
858
|
+
expect(items).not.toHaveBeenCalled()
|
|
859
|
+
// onBeforeStart should fire immediately (not debounced)
|
|
860
|
+
expect(onBeforeStart).toHaveBeenCalled()
|
|
861
|
+
|
|
862
|
+
// Advance past the debounce window
|
|
863
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
864
|
+
|
|
865
|
+
// Now items() should have been called
|
|
866
|
+
expect(items).toHaveBeenCalledTimes(1)
|
|
867
|
+
// onStart fires after items resolves
|
|
868
|
+
expect(onStart).toHaveBeenCalled()
|
|
869
|
+
|
|
870
|
+
vi.useRealTimers()
|
|
871
|
+
editor.destroy()
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it('should cancel pending debounce work on destroy', async () => {
|
|
875
|
+
vi.useFakeTimers()
|
|
876
|
+
|
|
877
|
+
const items = vi.fn().mockResolvedValue([])
|
|
878
|
+
const onBeforeStart = vi.fn()
|
|
879
|
+
const onStart = vi.fn()
|
|
880
|
+
|
|
881
|
+
const MentionExtension = Extension.create({
|
|
882
|
+
name: 'mention-debounce-destroy',
|
|
883
|
+
addProseMirrorPlugins() {
|
|
884
|
+
return [
|
|
885
|
+
Suggestion({
|
|
886
|
+
editor: this.editor,
|
|
887
|
+
char: '@',
|
|
888
|
+
debounce: 100,
|
|
889
|
+
items,
|
|
890
|
+
render: () => ({ onBeforeStart, onStart }),
|
|
891
|
+
}),
|
|
892
|
+
]
|
|
893
|
+
},
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
const editor = new Editor({
|
|
897
|
+
extensions: [StarterKit, MentionExtension],
|
|
898
|
+
content: '<p></p>',
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
editor.chain().insertContent('@').run()
|
|
902
|
+
await Promise.resolve()
|
|
903
|
+
|
|
904
|
+
expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
|
|
905
|
+
|
|
906
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
907
|
+
|
|
908
|
+
editor.destroy()
|
|
909
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
910
|
+
|
|
911
|
+
expect(items).not.toHaveBeenCalled()
|
|
912
|
+
expect(onStart.mock.calls.length).toBe(startCallsBefore)
|
|
913
|
+
|
|
914
|
+
vi.useRealTimers()
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
it('should reset the debounce timer on rapid typing', async () => {
|
|
918
|
+
vi.useFakeTimers()
|
|
919
|
+
|
|
920
|
+
const items = vi.fn().mockResolvedValue([])
|
|
921
|
+
|
|
922
|
+
const MentionExtension = Extension.create({
|
|
923
|
+
name: 'mention-debounce-rapid',
|
|
924
|
+
addProseMirrorPlugins() {
|
|
925
|
+
return [
|
|
926
|
+
Suggestion({
|
|
927
|
+
editor: this.editor,
|
|
928
|
+
char: '@',
|
|
929
|
+
debounce: 100,
|
|
930
|
+
items,
|
|
931
|
+
}),
|
|
932
|
+
]
|
|
933
|
+
},
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
const editor = new Editor({
|
|
937
|
+
extensions: [StarterKit, MentionExtension],
|
|
938
|
+
content: '<p></p>',
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
// Type @a
|
|
942
|
+
editor.chain().insertContent('@a').run()
|
|
943
|
+
await vi.advanceTimersByTimeAsync(60)
|
|
944
|
+
|
|
945
|
+
// Type b before debounce fires
|
|
946
|
+
editor.chain().insertContent('b').run()
|
|
947
|
+
await vi.advanceTimersByTimeAsync(60)
|
|
948
|
+
|
|
949
|
+
// Debounce should have reset — items not called yet
|
|
950
|
+
expect(items).not.toHaveBeenCalled()
|
|
951
|
+
|
|
952
|
+
// Advance past remaining debounce from last keystroke
|
|
953
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
954
|
+
|
|
955
|
+
// Should have been called only once (not twice)
|
|
956
|
+
expect(items).toHaveBeenCalledTimes(1)
|
|
957
|
+
|
|
958
|
+
vi.useRealTimers()
|
|
959
|
+
editor.destroy()
|
|
960
|
+
})
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
describe('suggestion positioning options', () => {
|
|
964
|
+
it('should forward placement, offset, container, and flip to SuggestionProps', async () => {
|
|
965
|
+
const onStart = vi.fn()
|
|
966
|
+
|
|
967
|
+
const MentionExtension = Extension.create({
|
|
968
|
+
name: 'mention-positioning',
|
|
969
|
+
addProseMirrorPlugins() {
|
|
970
|
+
return [
|
|
971
|
+
Suggestion({
|
|
972
|
+
editor: this.editor,
|
|
973
|
+
char: '@',
|
|
974
|
+
placement: 'top-start',
|
|
975
|
+
offset: { mainAxis: 8, crossAxis: 4 },
|
|
976
|
+
container: '.my-container',
|
|
977
|
+
flip: false,
|
|
978
|
+
render: () => ({ onStart }),
|
|
979
|
+
}),
|
|
980
|
+
]
|
|
981
|
+
},
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
const editor = new Editor({
|
|
985
|
+
extensions: [StarterKit, MentionExtension],
|
|
986
|
+
content: '<p></p>',
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
editor.chain().insertContent('@').run()
|
|
990
|
+
await Promise.resolve()
|
|
991
|
+
|
|
992
|
+
expect(onStart).toHaveBeenCalledWith(
|
|
993
|
+
expect.objectContaining({
|
|
994
|
+
placement: 'top-start',
|
|
995
|
+
offset: { mainAxis: 8, crossAxis: 4 },
|
|
996
|
+
container: '.my-container',
|
|
997
|
+
flip: false,
|
|
998
|
+
}),
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
editor.destroy()
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
it('should use defaults when positioning options are not set', async () => {
|
|
1005
|
+
const onStart = vi.fn()
|
|
1006
|
+
|
|
1007
|
+
const MentionExtension = Extension.create({
|
|
1008
|
+
name: 'mention-positioning-defaults',
|
|
1009
|
+
addProseMirrorPlugins() {
|
|
1010
|
+
return [
|
|
1011
|
+
Suggestion({
|
|
1012
|
+
editor: this.editor,
|
|
1013
|
+
char: '@',
|
|
1014
|
+
render: () => ({ onStart }),
|
|
1015
|
+
}),
|
|
1016
|
+
]
|
|
1017
|
+
},
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
const editor = new Editor({
|
|
1021
|
+
extensions: [StarterKit, MentionExtension],
|
|
1022
|
+
content: '<p></p>',
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
editor.chain().insertContent('@').run()
|
|
1026
|
+
await Promise.resolve()
|
|
1027
|
+
|
|
1028
|
+
expect(onStart).toHaveBeenCalledWith(
|
|
1029
|
+
expect.objectContaining({
|
|
1030
|
+
placement: 'bottom-start',
|
|
1031
|
+
offset: { mainAxis: 4, crossAxis: 0 },
|
|
1032
|
+
flip: true,
|
|
1033
|
+
}),
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
editor.destroy()
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
it('should pass through floatingUi config and middleware', async () => {
|
|
1040
|
+
const customMiddleware = {
|
|
1041
|
+
name: 'custom',
|
|
1042
|
+
fn: vi.fn(() => ({ x: 0, y: 0 })),
|
|
1043
|
+
} as Middleware
|
|
1044
|
+
|
|
1045
|
+
const onStart = vi.fn()
|
|
1046
|
+
|
|
1047
|
+
const MentionExtension = Extension.create({
|
|
1048
|
+
name: 'mention-floating-ui',
|
|
1049
|
+
addProseMirrorPlugins() {
|
|
1050
|
+
return [
|
|
1051
|
+
Suggestion({
|
|
1052
|
+
editor: this.editor,
|
|
1053
|
+
char: '@',
|
|
1054
|
+
placement: 'top-start',
|
|
1055
|
+
offset: { mainAxis: 8, crossAxis: 4 },
|
|
1056
|
+
flip: false,
|
|
1057
|
+
floatingUi: {
|
|
1058
|
+
strategy: 'fixed',
|
|
1059
|
+
middleware: [customMiddleware],
|
|
1060
|
+
},
|
|
1061
|
+
render: () => ({ onStart }),
|
|
1062
|
+
}),
|
|
1063
|
+
]
|
|
1064
|
+
},
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
const editor = new Editor({
|
|
1068
|
+
extensions: [StarterKit, MentionExtension],
|
|
1069
|
+
content: '<p></p>',
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
editor.chain().insertContent('@').run()
|
|
1073
|
+
await Promise.resolve()
|
|
1074
|
+
|
|
1075
|
+
expect(onStart).toHaveBeenCalledWith(
|
|
1076
|
+
expect.objectContaining({
|
|
1077
|
+
floatingUi: expect.objectContaining({
|
|
1078
|
+
placement: 'top-start',
|
|
1079
|
+
strategy: 'fixed',
|
|
1080
|
+
}),
|
|
1081
|
+
}),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
const floatingUi = onStart.mock.calls[0][0].floatingUi
|
|
1085
|
+
expect(floatingUi.middleware).toHaveLength(2)
|
|
1086
|
+
expect(floatingUi.middleware).toEqual(expect.arrayContaining([customMiddleware]))
|
|
1087
|
+
|
|
1088
|
+
editor.destroy()
|
|
1089
|
+
})
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
describe('suggestion mount', () => {
|
|
1093
|
+
// Captures `props.mount` from a started suggestion so each test can call it
|
|
1094
|
+
// against a real element.
|
|
1095
|
+
async function getMount(container?: string | HTMLElement) {
|
|
1096
|
+
const onStart = vi.fn()
|
|
1097
|
+
|
|
1098
|
+
const MentionExtension = Extension.create({
|
|
1099
|
+
name: 'mention-mount',
|
|
1100
|
+
addProseMirrorPlugins() {
|
|
1101
|
+
return [
|
|
1102
|
+
Suggestion({
|
|
1103
|
+
editor: this.editor,
|
|
1104
|
+
char: '@',
|
|
1105
|
+
container,
|
|
1106
|
+
render: () => ({ onStart }),
|
|
1107
|
+
}),
|
|
1108
|
+
]
|
|
1109
|
+
},
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
const editor = new Editor({
|
|
1113
|
+
extensions: [StarterKit, MentionExtension],
|
|
1114
|
+
content: '<p></p>',
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
editor.chain().insertContent('@').run()
|
|
1118
|
+
await Promise.resolve()
|
|
1119
|
+
|
|
1120
|
+
return { mount: onStart.mock.calls[0][0].mount, editor }
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
it('mounts the element into document.body and removes it on unmount', async () => {
|
|
1124
|
+
const { mount, editor } = await getMount()
|
|
1125
|
+
const element = document.createElement('div')
|
|
1126
|
+
|
|
1127
|
+
const unmount = mount(element)
|
|
1128
|
+
expect(element.parentElement).toBe(document.body)
|
|
1129
|
+
|
|
1130
|
+
unmount()
|
|
1131
|
+
expect(element.isConnected).toBe(false)
|
|
1132
|
+
|
|
1133
|
+
editor.destroy()
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
it('mounts the element into a provided container', async () => {
|
|
1137
|
+
const container = document.createElement('div')
|
|
1138
|
+
document.body.appendChild(container)
|
|
1139
|
+
|
|
1140
|
+
const { mount, editor } = await getMount(container)
|
|
1141
|
+
const element = document.createElement('div')
|
|
1142
|
+
|
|
1143
|
+
const unmount = mount(element)
|
|
1144
|
+
expect(element.parentElement).toBe(container)
|
|
1145
|
+
|
|
1146
|
+
unmount()
|
|
1147
|
+
container.remove()
|
|
1148
|
+
editor.destroy()
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
it('leaves an already-mounted element in place (escape hatch)', async () => {
|
|
1152
|
+
const { mount, editor } = await getMount()
|
|
1153
|
+
const element = document.createElement('div')
|
|
1154
|
+
const host = document.createElement('div')
|
|
1155
|
+
host.appendChild(element)
|
|
1156
|
+
document.body.appendChild(host)
|
|
1157
|
+
|
|
1158
|
+
const unmount = mount(element)
|
|
1159
|
+
expect(element.parentElement).toBe(host)
|
|
1160
|
+
|
|
1161
|
+
// We did not mount it, so unmount must not remove it.
|
|
1162
|
+
unmount()
|
|
1163
|
+
expect(element.parentElement).toBe(host)
|
|
1164
|
+
|
|
1165
|
+
host.remove()
|
|
1166
|
+
editor.destroy()
|
|
1167
|
+
})
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
describe('suggestion outside click', () => {
|
|
1171
|
+
async function setup(dismissOnOutsideClick?: boolean) {
|
|
1172
|
+
const onStart = vi.fn()
|
|
1173
|
+
|
|
1174
|
+
const MentionExtension = Extension.create({
|
|
1175
|
+
name: 'mention-outside-click',
|
|
1176
|
+
addProseMirrorPlugins() {
|
|
1177
|
+
return [
|
|
1178
|
+
Suggestion({
|
|
1179
|
+
editor: this.editor,
|
|
1180
|
+
char: '@',
|
|
1181
|
+
dismissOnOutsideClick,
|
|
1182
|
+
render: () => ({ onStart }),
|
|
1183
|
+
}),
|
|
1184
|
+
]
|
|
1185
|
+
},
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
const editor = new Editor({
|
|
1189
|
+
extensions: [StarterKit, MentionExtension],
|
|
1190
|
+
content: '<p></p>',
|
|
1191
|
+
})
|
|
1192
|
+
|
|
1193
|
+
editor.chain().insertContent('@').run()
|
|
1194
|
+
await Promise.resolve()
|
|
1195
|
+
|
|
1196
|
+
const mount = onStart.mock.calls[0][0].mount
|
|
1197
|
+
const isActive = () => SuggestionPluginKey.getState(editor.state)?.active === true
|
|
1198
|
+
|
|
1199
|
+
return { mount, editor, isActive }
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
it('dismisses when clicking outside the popup and editor', async () => {
|
|
1203
|
+
const { mount, editor, isActive } = await setup()
|
|
1204
|
+
const element = document.createElement('div')
|
|
1205
|
+
const unmount = mount(element)
|
|
1206
|
+
|
|
1207
|
+
expect(isActive()).toBe(true)
|
|
1208
|
+
|
|
1209
|
+
const outside = document.createElement('div')
|
|
1210
|
+
document.body.appendChild(outside)
|
|
1211
|
+
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
|
1212
|
+
|
|
1213
|
+
expect(isActive()).toBe(false)
|
|
1214
|
+
|
|
1215
|
+
unmount()
|
|
1216
|
+
outside.remove()
|
|
1217
|
+
editor.destroy()
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
it('does not dismiss when clicking inside the popup', async () => {
|
|
1221
|
+
const { mount, editor, isActive } = await setup()
|
|
1222
|
+
const element = document.createElement('div')
|
|
1223
|
+
const unmount = mount(element)
|
|
1224
|
+
|
|
1225
|
+
element.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
|
1226
|
+
|
|
1227
|
+
expect(isActive()).toBe(true)
|
|
1228
|
+
|
|
1229
|
+
unmount()
|
|
1230
|
+
editor.destroy()
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
it('does not attach the listener when dismissOnOutsideClick is false', async () => {
|
|
1234
|
+
const { mount, editor, isActive } = await setup(false)
|
|
1235
|
+
const element = document.createElement('div')
|
|
1236
|
+
const unmount = mount(element)
|
|
1237
|
+
|
|
1238
|
+
const outside = document.createElement('div')
|
|
1239
|
+
document.body.appendChild(outside)
|
|
1240
|
+
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
|
1241
|
+
|
|
1242
|
+
expect(isActive()).toBe(true)
|
|
1243
|
+
|
|
1244
|
+
unmount()
|
|
1245
|
+
outside.remove()
|
|
1246
|
+
editor.destroy()
|
|
1247
|
+
})
|
|
1248
|
+
})
|