@tiptap/react 3.17.0 → 3.18.0

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.
@@ -1,6 +1,6 @@
1
1
  import { type BubbleMenuPluginProps, BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'
2
2
  import { useCurrentEditor } from '@tiptap/react'
3
- import React, { useEffect, useRef } from 'react'
3
+ import React, { useEffect, useRef, useState } from 'react'
4
4
  import { createPortal } from 'react-dom'
5
5
 
6
6
  type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
@@ -58,6 +58,18 @@ export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
58
58
  const bubbleMenuPluginPropsRef = useRef(bubbleMenuPluginProps)
59
59
  bubbleMenuPluginPropsRef.current = bubbleMenuPluginProps
60
60
 
61
+ /**
62
+ * Track whether the plugin has been initialized, so we only send updates
63
+ * after the initial registration.
64
+ */
65
+ const [pluginInitialized, setPluginInitialized] = useState(false)
66
+
67
+ /**
68
+ * Track whether we need to skip the first options update dispatch.
69
+ * This prevents unnecessary updates right after plugin initialization.
70
+ */
71
+ const skipFirstUpdateRef = useRef(true)
72
+
61
73
  useEffect(() => {
62
74
  if (pluginEditor?.isDestroyed) {
63
75
  return
@@ -82,7 +94,11 @@ export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
82
94
 
83
95
  const createdPluginKey = bubbleMenuPluginPropsRef.current.pluginKey
84
96
 
97
+ skipFirstUpdateRef.current = true
98
+ setPluginInitialized(true)
99
+
85
100
  return () => {
101
+ setPluginInitialized(false)
86
102
  pluginEditor.unregisterPlugin(createdPluginKey)
87
103
  window.requestAnimationFrame(() => {
88
104
  if (bubbleMenuElement.parentNode) {
@@ -92,6 +108,38 @@ export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
92
108
  }
93
109
  }, [pluginEditor])
94
110
 
111
+ /**
112
+ * Update the plugin options when props change after the plugin has been initialized.
113
+ * This allows dynamic updates to options like scrollTarget without re-registering the entire plugin.
114
+ */
115
+ useEffect(() => {
116
+ if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {
117
+ return
118
+ }
119
+
120
+ // Skip the first update right after initialization since the plugin was just created with these options
121
+ if (skipFirstUpdateRef.current) {
122
+ skipFirstUpdateRef.current = false
123
+ return
124
+ }
125
+
126
+ pluginEditor.view.dispatch(
127
+ pluginEditor.state.tr.setMeta('bubbleMenu', {
128
+ type: 'updateOptions',
129
+ options: bubbleMenuPluginPropsRef.current,
130
+ }),
131
+ )
132
+ }, [
133
+ pluginInitialized,
134
+ pluginEditor,
135
+ updateDelay,
136
+ resizeDelay,
137
+ shouldShow,
138
+ options,
139
+ appendTo,
140
+ getReferencedVirtualElement,
141
+ ])
142
+
95
143
  return createPortal(<div {...restProps}>{children}</div>, menuEl.current)
96
144
  },
97
145
  )
@@ -1,7 +1,7 @@
1
1
  import type { FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
2
2
  import { FloatingMenuPlugin } from '@tiptap/extension-floating-menu'
3
3
  import { useCurrentEditor } from '@tiptap/react'
4
- import React, { useEffect, useRef } from 'react'
4
+ import React, { useEffect, useRef, useState } from 'react'
5
5
  import { createPortal } from 'react-dom'
6
6
 
7
7
  type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
@@ -36,48 +36,104 @@ export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
36
36
 
37
37
  const { editor: currentEditor } = useCurrentEditor()
38
38
 
39
- useEffect(() => {
40
- const floatingMenuElement = menuEl.current
39
+ /**
40
+ * The editor instance where the floating menu plugin will be registered.
41
+ */
42
+ const pluginEditor = editor || currentEditor
41
43
 
42
- floatingMenuElement.style.visibility = 'hidden'
43
- floatingMenuElement.style.position = 'absolute'
44
+ // Creating a useMemo would be more computationally expensive than just
45
+ // re-creating this object on every render.
46
+ const floatingMenuPluginProps: Omit<FloatingMenuPluginProps, 'editor' | 'element'> = {
47
+ updateDelay,
48
+ resizeDelay,
49
+ appendTo,
50
+ pluginKey,
51
+ shouldShow,
52
+ options,
53
+ }
54
+
55
+ /**
56
+ * The props for the floating menu plugin. They are accessed inside a ref to
57
+ * avoid running the useEffect hook and re-registering the plugin when the
58
+ * props change.
59
+ */
60
+ const floatingMenuPluginPropsRef = useRef(floatingMenuPluginProps)
61
+ floatingMenuPluginPropsRef.current = floatingMenuPluginProps
62
+
63
+ /**
64
+ * Track whether the plugin has been initialized, so we only send updates
65
+ * after the initial registration.
66
+ */
67
+ const [pluginInitialized, setPluginInitialized] = useState(false)
44
68
 
45
- if (editor?.isDestroyed || (currentEditor as any)?.isDestroyed) {
69
+ /**
70
+ * Track whether we need to skip the first options update dispatch.
71
+ * This prevents unnecessary updates right after plugin initialization.
72
+ */
73
+ const skipFirstUpdateRef = useRef(true)
74
+
75
+ useEffect(() => {
76
+ if (pluginEditor?.isDestroyed) {
46
77
  return
47
78
  }
48
79
 
49
- const attachToEditor = editor || currentEditor
50
-
51
- if (!attachToEditor) {
80
+ if (!pluginEditor) {
52
81
  console.warn(
53
82
  'FloatingMenu component is not rendered inside of an editor component or does not have editor prop.',
54
83
  )
55
84
  return
56
85
  }
57
86
 
87
+ const floatingMenuElement = menuEl.current
88
+ floatingMenuElement.style.visibility = 'hidden'
89
+ floatingMenuElement.style.position = 'absolute'
90
+
58
91
  const plugin = FloatingMenuPlugin({
59
- editor: attachToEditor,
92
+ ...floatingMenuPluginPropsRef.current,
93
+ editor: pluginEditor,
60
94
  element: floatingMenuElement,
61
- pluginKey,
62
- updateDelay,
63
- resizeDelay,
64
- appendTo,
65
- shouldShow,
66
- options,
67
95
  })
68
96
 
69
- attachToEditor.registerPlugin(plugin)
97
+ pluginEditor.registerPlugin(plugin)
98
+
99
+ const createdPluginKey = floatingMenuPluginPropsRef.current.pluginKey
100
+
101
+ skipFirstUpdateRef.current = true
102
+ setPluginInitialized(true)
70
103
 
71
104
  return () => {
72
- attachToEditor.unregisterPlugin(pluginKey)
105
+ setPluginInitialized(false)
106
+ pluginEditor.unregisterPlugin(createdPluginKey)
73
107
  window.requestAnimationFrame(() => {
74
108
  if (floatingMenuElement.parentNode) {
75
109
  floatingMenuElement.parentNode.removeChild(floatingMenuElement)
76
110
  }
77
111
  })
78
112
  }
79
- // eslint-disable-next-line react-hooks/exhaustive-deps
80
- }, [editor, currentEditor, appendTo, pluginKey, shouldShow, options, updateDelay, resizeDelay])
113
+ }, [pluginEditor])
114
+
115
+ /**
116
+ * Update the plugin options when props change after the plugin has been initialized.
117
+ * This allows dynamic updates to options like scrollTarget without re-registering the entire plugin.
118
+ */
119
+ useEffect(() => {
120
+ if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {
121
+ return
122
+ }
123
+
124
+ // Skip the first update right after initialization since the plugin was just created with these options
125
+ if (skipFirstUpdateRef.current) {
126
+ skipFirstUpdateRef.current = false
127
+ return
128
+ }
129
+
130
+ pluginEditor.view.dispatch(
131
+ pluginEditor.state.tr.setMeta('floatingMenu', {
132
+ type: 'updateOptions',
133
+ options: floatingMenuPluginPropsRef.current,
134
+ }),
135
+ )
136
+ }, [pluginInitialized, pluginEditor, updateDelay, resizeDelay, shouldShow, options, appendTo])
81
137
 
82
138
  return createPortal(<div {...restProps}>{children}</div>, menuEl.current)
83
139
  },