@tiptap/react 3.20.1 → 3.20.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiptap/react",
3
3
  "description": "React components for tiptap",
4
- "version": "3.20.1",
4
+ "version": "3.20.3",
5
5
  "homepage": "https://tiptap.dev",
6
6
  "keywords": [
7
7
  "tiptap",
@@ -48,20 +48,20 @@
48
48
  "@types/react-dom": "^19.0.0",
49
49
  "react": "^19.0.0",
50
50
  "react-dom": "^19.0.0",
51
- "@tiptap/core": "^3.20.1",
52
- "@tiptap/pm": "^3.20.1"
51
+ "@tiptap/core": "^3.20.3",
52
+ "@tiptap/pm": "^3.20.3"
53
53
  },
54
54
  "optionalDependencies": {
55
- "@tiptap/extension-bubble-menu": "^3.20.1",
56
- "@tiptap/extension-floating-menu": "^3.20.1"
55
+ "@tiptap/extension-bubble-menu": "^3.20.3",
56
+ "@tiptap/extension-floating-menu": "^3.20.3"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
60
60
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
61
61
  "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
62
62
  "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
63
- "@tiptap/core": "^3.20.1",
64
- "@tiptap/pm": "^3.20.1"
63
+ "@tiptap/core": "^3.20.3",
64
+ "@tiptap/pm": "^3.20.3"
65
65
  },
66
66
  "repository": {
67
67
  "type": "git",
@@ -0,0 +1,174 @@
1
+ import { render } from '@testing-library/react'
2
+ import React, { createRef } from 'react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { BubbleMenu } from './BubbleMenu.js'
6
+
7
+ const { bubbleMenuPluginMock } = vi.hoisted(() => ({
8
+ bubbleMenuPluginMock: vi.fn(() => ({ key: 'bubble-menu-plugin' })),
9
+ }))
10
+
11
+ vi.mock('@tiptap/extension-bubble-menu', () => ({
12
+ BubbleMenuPlugin: bubbleMenuPluginMock,
13
+ }))
14
+
15
+ function createEditor() {
16
+ const tr = {
17
+ setMeta: vi.fn(() => tr),
18
+ }
19
+
20
+ return {
21
+ isDestroyed: false,
22
+ registerPlugin: vi.fn(),
23
+ unregisterPlugin: vi.fn(),
24
+ view: {
25
+ dispatch: vi.fn(),
26
+ },
27
+ state: {
28
+ tr,
29
+ },
30
+ }
31
+ }
32
+
33
+ describe('BubbleMenu', () => {
34
+ beforeEach(() => {
35
+ bubbleMenuPluginMock.mockClear()
36
+ })
37
+
38
+ afterEach(() => {
39
+ document.body.innerHTML = ''
40
+ })
41
+
42
+ it('applies html props to the actual menu element', () => {
43
+ const editor = createEditor()
44
+ const ref = createRef<HTMLDivElement>()
45
+ const handleClick = vi.fn()
46
+ let lastEvent: any
47
+ const handleClickCapture = vi.fn()
48
+ const handleDoubleClick = vi.fn()
49
+ const initialProps = {
50
+ editor: editor as never,
51
+ className: 'bubble-menu',
52
+ 'data-testid': 'menu-element',
53
+ 'aria-label': 'Bubble menu',
54
+ style: { zIndex: 9999, marginTop: 8, position: 'relative' },
55
+ onClick: (event: unknown) => {
56
+ lastEvent = event
57
+ handleClick()
58
+ },
59
+ onClickCapture: handleClickCapture,
60
+ onDoubleClick: handleDoubleClick,
61
+ dangerouslySetInnerHTML: { __html: 'ignored' },
62
+ pluginKey: 'bubbleMenu',
63
+ tabIndex: 3,
64
+ ref,
65
+ children: React.createElement('button', { type: 'button' }, 'Menu action'),
66
+ } as any
67
+ const updatedProps = {
68
+ editor: editor as never,
69
+ className: 'bubble-menu-updated',
70
+ style: { zIndex: 1000, marginTop: 16, position: 'static' },
71
+ onClick: undefined,
72
+ onClickCapture: undefined,
73
+ onDoubleClick: undefined,
74
+ pluginKey: 'bubbleMenu',
75
+ tabIndex: undefined,
76
+ ref,
77
+ children: React.createElement('button', { type: 'button' }, 'Updated action'),
78
+ } as any
79
+
80
+ const { rerender, unmount } = render(React.createElement(BubbleMenu, initialProps))
81
+
82
+ expect(editor.registerPlugin).toHaveBeenCalledTimes(1)
83
+ expect(bubbleMenuPluginMock).toHaveBeenCalledTimes(1)
84
+
85
+ const [{ element }] = bubbleMenuPluginMock.mock.calls[0] as unknown as [{ element: HTMLDivElement }]
86
+
87
+ expect(element).toBeInstanceOf(HTMLDivElement)
88
+ expect(element).toBe(ref.current)
89
+ expect(element.className).toBe('bubble-menu')
90
+ expect(element.getAttribute('data-testid')).toBe('menu-element')
91
+ expect(element.getAttribute('aria-label')).toBe('Bubble menu')
92
+ expect(element.tabIndex).toBe(3)
93
+ expect(element.style.zIndex).toBe('9999')
94
+ expect(element.style.marginTop).toBe('8px')
95
+ expect(element.style.position).toBe('absolute')
96
+ expect(element.getAttribute('dangerouslySetInnerHTML')).toBeNull()
97
+
98
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
99
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
100
+ element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
101
+
102
+ expect(handleClick).toHaveBeenCalledTimes(2)
103
+ expect(handleDoubleClick).toHaveBeenCalledTimes(1)
104
+ expect(handleClickCapture).toHaveBeenCalledTimes(2)
105
+ expect(lastEvent.nativeEvent).toBeInstanceOf(MouseEvent)
106
+ expect(lastEvent.currentTarget).toBe(element)
107
+ expect(lastEvent.target).toBeInstanceOf(Element)
108
+ expect(typeof lastEvent.persist).toBe('function')
109
+ expect(element.textContent).toContain('Menu action')
110
+
111
+ rerender(React.createElement(BubbleMenu, updatedProps))
112
+
113
+ expect(element.className).toBe('bubble-menu-updated')
114
+ expect(element.getAttribute('data-testid')).toBeNull()
115
+ expect(element.getAttribute('aria-label')).toBeNull()
116
+ expect(element.tabIndex).toBe(-1)
117
+ expect(element.style.zIndex).toBe('1000')
118
+ expect(element.style.marginTop).toBe('16px')
119
+ expect(element.style.position).toBe('absolute')
120
+
121
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
122
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
123
+ element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
124
+
125
+ expect(handleClick).toHaveBeenCalledTimes(2)
126
+ expect(handleDoubleClick).toHaveBeenCalledTimes(1)
127
+ expect(handleClickCapture).toHaveBeenCalledTimes(2)
128
+ expect(element.textContent).toContain('Updated action')
129
+
130
+ unmount()
131
+
132
+ expect(editor.unregisterPlugin).toHaveBeenCalledWith('bubbleMenu')
133
+ })
134
+
135
+ it('creates unique plugin keys when none are provided', () => {
136
+ const editor = createEditor()
137
+ const shouldShowA = vi.fn(() => true)
138
+ const shouldShowB = vi.fn(() => false)
139
+
140
+ const { unmount } = render(
141
+ React.createElement(
142
+ React.Fragment,
143
+ null,
144
+ React.createElement(BubbleMenu, {
145
+ editor: editor as never,
146
+ shouldShow: shouldShowA,
147
+ children: React.createElement('button', { type: 'button' }, 'First'),
148
+ } as any),
149
+ React.createElement(BubbleMenu, {
150
+ editor: editor as never,
151
+ shouldShow: shouldShowB,
152
+ children: React.createElement('button', { type: 'button' }, 'Second'),
153
+ } as any),
154
+ ),
155
+ )
156
+
157
+ expect(bubbleMenuPluginMock).toHaveBeenCalledTimes(2)
158
+
159
+ const pluginCalls = bubbleMenuPluginMock.mock.calls as unknown as Array<[{ pluginKey: unknown }]>
160
+ const firstPluginKey = pluginCalls[0][0].pluginKey
161
+ const secondPluginKey = pluginCalls[1][0].pluginKey
162
+
163
+ expect(firstPluginKey).toBeDefined()
164
+ expect(secondPluginKey).toBeDefined()
165
+ expect(firstPluginKey).not.toBe(secondPluginKey)
166
+
167
+ unmount()
168
+
169
+ const unregisterCalls = editor.unregisterPlugin.mock.calls as unknown as Array<[unknown]>
170
+
171
+ expect(unregisterCalls.some(([key]) => key === firstPluginKey)).toBe(true)
172
+ expect(unregisterCalls.some(([key]) => key === secondPluginKey)).toBe(true)
173
+ })
174
+ })
@@ -1,8 +1,12 @@
1
1
  import { type BubbleMenuPluginProps, BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'
2
+ import type { PluginKey } from '@tiptap/pm/state'
2
3
  import { useCurrentEditor } from '@tiptap/react'
3
4
  import React, { useEffect, useRef, useState } from 'react'
4
5
  import { createPortal } from 'react-dom'
5
6
 
7
+ import { getAutoPluginKey } from './getAutoPluginKey.js'
8
+ import { useMenuElementProps } from './useMenuElementProps.js'
9
+
6
10
  type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
7
11
 
8
12
  export type BubbleMenuProps = Optional<Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>, 'editor'> &
@@ -11,7 +15,7 @@ export type BubbleMenuProps = Optional<Omit<Optional<BubbleMenuPluginProps, 'plu
11
15
  export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
12
16
  (
13
17
  {
14
- pluginKey = 'bubbleMenu',
18
+ pluginKey,
15
19
  editor,
16
20
  updateDelay,
17
21
  resizeDelay,
@@ -25,6 +29,9 @@ export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
25
29
  ref,
26
30
  ) => {
27
31
  const menuEl = useRef(document.createElement('div'))
32
+ const resolvedPluginKey = useRef<PluginKey | string>(getAutoPluginKey(pluginKey, 'bubbleMenu')).current
33
+
34
+ useMenuElementProps(menuEl.current, restProps)
28
35
 
29
36
  if (typeof ref === 'function') {
30
37
  ref(menuEl.current)
@@ -45,7 +52,7 @@ export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
45
52
  updateDelay,
46
53
  resizeDelay,
47
54
  appendTo,
48
- pluginKey,
55
+ pluginKey: resolvedPluginKey,
49
56
  shouldShow,
50
57
  getReferencedVirtualElement,
51
58
  options,
@@ -124,7 +131,7 @@ export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
124
131
  }
125
132
 
126
133
  pluginEditor.view.dispatch(
127
- pluginEditor.state.tr.setMeta(pluginKey, {
134
+ pluginEditor.state.tr.setMeta(resolvedPluginKey, {
128
135
  type: 'updateOptions',
129
136
  options: bubbleMenuPluginPropsRef.current,
130
137
  }),
@@ -138,8 +145,9 @@ export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
138
145
  options,
139
146
  appendTo,
140
147
  getReferencedVirtualElement,
148
+ resolvedPluginKey,
141
149
  ])
142
150
 
143
- return createPortal(<div {...restProps}>{children}</div>, menuEl.current)
151
+ return createPortal(children, menuEl.current)
144
152
  },
145
153
  )
@@ -0,0 +1,164 @@
1
+ import { render } from '@testing-library/react'
2
+ import React, { createRef } from 'react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { FloatingMenu } from './FloatingMenu.js'
6
+
7
+ const { floatingMenuPluginMock } = vi.hoisted(() => ({
8
+ floatingMenuPluginMock: vi.fn(() => ({ key: 'floating-menu-plugin' })),
9
+ }))
10
+
11
+ vi.mock('@tiptap/extension-floating-menu', () => ({
12
+ FloatingMenuPlugin: floatingMenuPluginMock,
13
+ }))
14
+
15
+ function createEditor() {
16
+ const tr = {
17
+ setMeta: vi.fn(() => tr),
18
+ }
19
+
20
+ return {
21
+ isDestroyed: false,
22
+ registerPlugin: vi.fn(),
23
+ unregisterPlugin: vi.fn(),
24
+ view: {
25
+ dispatch: vi.fn(),
26
+ },
27
+ state: {
28
+ tr,
29
+ },
30
+ }
31
+ }
32
+
33
+ describe('FloatingMenu', () => {
34
+ beforeEach(() => {
35
+ floatingMenuPluginMock.mockClear()
36
+ })
37
+
38
+ afterEach(() => {
39
+ document.body.innerHTML = ''
40
+ })
41
+
42
+ it('applies html props to the actual menu element', () => {
43
+ const editor = createEditor()
44
+ const ref = createRef<HTMLDivElement>()
45
+ const handleClick = vi.fn()
46
+ const handleClickCapture = vi.fn()
47
+ const handleDoubleClick = vi.fn()
48
+ const initialProps = {
49
+ editor: editor as never,
50
+ className: 'floating-menu',
51
+ 'data-testid': 'floating-element',
52
+ 'aria-label': 'Floating menu',
53
+ style: { zIndex: 8888, marginTop: 12, position: 'relative' },
54
+ onClick: handleClick,
55
+ onClickCapture: handleClickCapture,
56
+ onDoubleClick: handleDoubleClick,
57
+ pluginKey: 'floatingMenu',
58
+ tabIndex: 5,
59
+ ref,
60
+ children: React.createElement('button', { type: 'button' }, 'Floating action'),
61
+ } as any
62
+ const updatedProps = {
63
+ editor: editor as never,
64
+ className: 'floating-menu-updated',
65
+ style: { zIndex: 7777, marginTop: 20, position: 'static' },
66
+ onClick: undefined,
67
+ onClickCapture: undefined,
68
+ onDoubleClick: undefined,
69
+ pluginKey: 'floatingMenu',
70
+ tabIndex: undefined,
71
+ ref,
72
+ children: React.createElement('button', { type: 'button' }, 'Updated floating action'),
73
+ } as any
74
+
75
+ const { rerender, unmount } = render(React.createElement(FloatingMenu, initialProps))
76
+
77
+ expect(editor.registerPlugin).toHaveBeenCalledTimes(1)
78
+ expect(floatingMenuPluginMock).toHaveBeenCalledTimes(1)
79
+
80
+ const [{ element }] = floatingMenuPluginMock.mock.calls[0] as unknown as [{ element: HTMLDivElement }]
81
+
82
+ expect(element).toBeInstanceOf(HTMLDivElement)
83
+ expect(element).toBe(ref.current)
84
+ expect(element.className).toBe('floating-menu')
85
+ expect(element.getAttribute('data-testid')).toBe('floating-element')
86
+ expect(element.getAttribute('aria-label')).toBe('Floating menu')
87
+ expect(element.tabIndex).toBe(5)
88
+ expect(element.style.zIndex).toBe('8888')
89
+ expect(element.style.marginTop).toBe('12px')
90
+ expect(element.style.position).toBe('absolute')
91
+
92
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
93
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
94
+ element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
95
+
96
+ expect(handleClick).toHaveBeenCalledTimes(2)
97
+ expect(handleDoubleClick).toHaveBeenCalledTimes(1)
98
+ expect(handleClickCapture).toHaveBeenCalledTimes(2)
99
+ expect(element.textContent).toContain('Floating action')
100
+
101
+ rerender(React.createElement(FloatingMenu, updatedProps))
102
+
103
+ expect(element.className).toBe('floating-menu-updated')
104
+ expect(element.getAttribute('data-testid')).toBeNull()
105
+ expect(element.getAttribute('aria-label')).toBeNull()
106
+ expect(element.tabIndex).toBe(-1)
107
+ expect(element.style.zIndex).toBe('7777')
108
+ expect(element.style.marginTop).toBe('20px')
109
+ expect(element.style.position).toBe('absolute')
110
+
111
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
112
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
113
+ element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
114
+
115
+ expect(handleClick).toHaveBeenCalledTimes(2)
116
+ expect(handleDoubleClick).toHaveBeenCalledTimes(1)
117
+ expect(handleClickCapture).toHaveBeenCalledTimes(2)
118
+ expect(element.textContent).toContain('Updated floating action')
119
+
120
+ unmount()
121
+
122
+ expect(editor.unregisterPlugin).toHaveBeenCalledWith('floatingMenu')
123
+ })
124
+
125
+ it('creates unique plugin keys when none are provided', () => {
126
+ const editor = createEditor()
127
+ const shouldShowA = vi.fn(() => true)
128
+ const shouldShowB = vi.fn(() => false)
129
+
130
+ const { unmount } = render(
131
+ React.createElement(
132
+ React.Fragment,
133
+ null,
134
+ React.createElement(FloatingMenu, {
135
+ editor: editor as never,
136
+ shouldShow: shouldShowA,
137
+ children: React.createElement('button', { type: 'button' }, 'First'),
138
+ } as any),
139
+ React.createElement(FloatingMenu, {
140
+ editor: editor as never,
141
+ shouldShow: shouldShowB,
142
+ children: React.createElement('button', { type: 'button' }, 'Second'),
143
+ } as any),
144
+ ),
145
+ )
146
+
147
+ expect(floatingMenuPluginMock).toHaveBeenCalledTimes(2)
148
+
149
+ const pluginCalls = floatingMenuPluginMock.mock.calls as unknown as Array<[{ pluginKey: unknown }]>
150
+ const firstPluginKey = pluginCalls[0][0].pluginKey
151
+ const secondPluginKey = pluginCalls[1][0].pluginKey
152
+
153
+ expect(firstPluginKey).toBeDefined()
154
+ expect(secondPluginKey).toBeDefined()
155
+ expect(firstPluginKey).not.toBe(secondPluginKey)
156
+
157
+ unmount()
158
+
159
+ const unregisterCalls = editor.unregisterPlugin.mock.calls as unknown as Array<[unknown]>
160
+
161
+ expect(unregisterCalls.some(([key]) => key === firstPluginKey)).toBe(true)
162
+ expect(unregisterCalls.some(([key]) => key === secondPluginKey)).toBe(true)
163
+ })
164
+ })
@@ -1,9 +1,13 @@
1
1
  import type { FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
2
2
  import { FloatingMenuPlugin } from '@tiptap/extension-floating-menu'
3
+ import type { PluginKey } from '@tiptap/pm/state'
3
4
  import { useCurrentEditor } from '@tiptap/react'
4
5
  import React, { useEffect, useRef, useState } from 'react'
5
6
  import { createPortal } from 'react-dom'
6
7
 
8
+ import { getAutoPluginKey } from './getAutoPluginKey.js'
9
+ import { useMenuElementProps } from './useMenuElementProps.js'
10
+
7
11
  type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
8
12
 
9
13
  export type FloatingMenuProps = Omit<Optional<FloatingMenuPluginProps, 'pluginKey'>, 'element' | 'editor'> & {
@@ -13,20 +17,13 @@ export type FloatingMenuProps = Omit<Optional<FloatingMenuPluginProps, 'pluginKe
13
17
 
14
18
  export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
15
19
  (
16
- {
17
- pluginKey = 'floatingMenu',
18
- editor,
19
- updateDelay,
20
- resizeDelay,
21
- appendTo,
22
- shouldShow = null,
23
- options,
24
- children,
25
- ...restProps
26
- },
20
+ { pluginKey, editor, updateDelay, resizeDelay, appendTo, shouldShow = null, options, children, ...restProps },
27
21
  ref,
28
22
  ) => {
29
23
  const menuEl = useRef(document.createElement('div'))
24
+ const resolvedPluginKey = useRef<PluginKey | string>(getAutoPluginKey(pluginKey, 'floatingMenu')).current
25
+
26
+ useMenuElementProps(menuEl.current, restProps)
30
27
 
31
28
  if (typeof ref === 'function') {
32
29
  ref(menuEl.current)
@@ -47,7 +44,7 @@ export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
47
44
  updateDelay,
48
45
  resizeDelay,
49
46
  appendTo,
50
- pluginKey,
47
+ pluginKey: resolvedPluginKey,
51
48
  shouldShow,
52
49
  options,
53
50
  }
@@ -128,13 +125,13 @@ export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
128
125
  }
129
126
 
130
127
  pluginEditor.view.dispatch(
131
- pluginEditor.state.tr.setMeta(pluginKey, {
128
+ pluginEditor.state.tr.setMeta(resolvedPluginKey, {
132
129
  type: 'updateOptions',
133
130
  options: floatingMenuPluginPropsRef.current,
134
131
  }),
135
132
  )
136
- }, [pluginInitialized, pluginEditor, updateDelay, resizeDelay, shouldShow, options, appendTo])
133
+ }, [pluginInitialized, pluginEditor, updateDelay, resizeDelay, shouldShow, options, appendTo, resolvedPluginKey])
137
134
 
138
- return createPortal(<div {...restProps}>{children}</div>, menuEl.current)
135
+ return createPortal(children, menuEl.current)
139
136
  },
140
137
  )
@@ -0,0 +1,5 @@
1
+ import { PluginKey } from '@tiptap/pm/state'
2
+
3
+ export function getAutoPluginKey(pluginKey: PluginKey | string | undefined, defaultName: string) {
4
+ return pluginKey ?? new PluginKey(defaultName)
5
+ }