@tiptap/react 3.17.1 → 3.19.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,7 +1,7 @@
1
1
  // src/menus/BubbleMenu.tsx
2
2
  import { BubbleMenuPlugin } from "@tiptap/extension-bubble-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
  import { jsx } from "react/jsx-runtime";
7
7
  var BubbleMenu = React.forwardRef(
@@ -36,6 +36,8 @@ var BubbleMenu = React.forwardRef(
36
36
  };
37
37
  const bubbleMenuPluginPropsRef = useRef(bubbleMenuPluginProps);
38
38
  bubbleMenuPluginPropsRef.current = bubbleMenuPluginProps;
39
+ const [pluginInitialized, setPluginInitialized] = useState(false);
40
+ const skipFirstUpdateRef = useRef(true);
39
41
  useEffect(() => {
40
42
  if (pluginEditor == null ? void 0 : pluginEditor.isDestroyed) {
41
43
  return;
@@ -54,7 +56,10 @@ var BubbleMenu = React.forwardRef(
54
56
  });
55
57
  pluginEditor.registerPlugin(plugin);
56
58
  const createdPluginKey = bubbleMenuPluginPropsRef.current.pluginKey;
59
+ skipFirstUpdateRef.current = true;
60
+ setPluginInitialized(true);
57
61
  return () => {
62
+ setPluginInitialized(false);
58
63
  pluginEditor.unregisterPlugin(createdPluginKey);
59
64
  window.requestAnimationFrame(() => {
60
65
  if (bubbleMenuElement.parentNode) {
@@ -63,6 +68,30 @@ var BubbleMenu = React.forwardRef(
63
68
  });
64
69
  };
65
70
  }, [pluginEditor]);
71
+ useEffect(() => {
72
+ if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {
73
+ return;
74
+ }
75
+ if (skipFirstUpdateRef.current) {
76
+ skipFirstUpdateRef.current = false;
77
+ return;
78
+ }
79
+ pluginEditor.view.dispatch(
80
+ pluginEditor.state.tr.setMeta("bubbleMenu", {
81
+ type: "updateOptions",
82
+ options: bubbleMenuPluginPropsRef.current
83
+ })
84
+ );
85
+ }, [
86
+ pluginInitialized,
87
+ pluginEditor,
88
+ updateDelay,
89
+ resizeDelay,
90
+ shouldShow,
91
+ options,
92
+ appendTo,
93
+ getReferencedVirtualElement
94
+ ]);
66
95
  return createPortal(/* @__PURE__ */ jsx("div", { ...restProps, children }), menuEl.current);
67
96
  }
68
97
  );
@@ -70,7 +99,7 @@ var BubbleMenu = React.forwardRef(
70
99
  // src/menus/FloatingMenu.tsx
71
100
  import { FloatingMenuPlugin } from "@tiptap/extension-floating-menu";
72
101
  import { useCurrentEditor as useCurrentEditor2 } from "@tiptap/react";
73
- import React2, { useEffect as useEffect2, useRef as useRef2 } from "react";
102
+ import React2, { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
74
103
  import { createPortal as createPortal2 } from "react-dom";
75
104
  import { jsx as jsx2 } from "react/jsx-runtime";
76
105
  var FloatingMenu = React2.forwardRef(
@@ -92,40 +121,66 @@ var FloatingMenu = React2.forwardRef(
92
121
  ref.current = menuEl.current;
93
122
  }
94
123
  const { editor: currentEditor } = useCurrentEditor2();
124
+ const pluginEditor = editor || currentEditor;
125
+ const floatingMenuPluginProps = {
126
+ updateDelay,
127
+ resizeDelay,
128
+ appendTo,
129
+ pluginKey,
130
+ shouldShow,
131
+ options
132
+ };
133
+ const floatingMenuPluginPropsRef = useRef2(floatingMenuPluginProps);
134
+ floatingMenuPluginPropsRef.current = floatingMenuPluginProps;
135
+ const [pluginInitialized, setPluginInitialized] = useState2(false);
136
+ const skipFirstUpdateRef = useRef2(true);
95
137
  useEffect2(() => {
96
- const floatingMenuElement = menuEl.current;
97
- floatingMenuElement.style.visibility = "hidden";
98
- floatingMenuElement.style.position = "absolute";
99
- if ((editor == null ? void 0 : editor.isDestroyed) || (currentEditor == null ? void 0 : currentEditor.isDestroyed)) {
138
+ if (pluginEditor == null ? void 0 : pluginEditor.isDestroyed) {
100
139
  return;
101
140
  }
102
- const attachToEditor = editor || currentEditor;
103
- if (!attachToEditor) {
141
+ if (!pluginEditor) {
104
142
  console.warn(
105
143
  "FloatingMenu component is not rendered inside of an editor component or does not have editor prop."
106
144
  );
107
145
  return;
108
146
  }
147
+ const floatingMenuElement = menuEl.current;
148
+ floatingMenuElement.style.visibility = "hidden";
149
+ floatingMenuElement.style.position = "absolute";
109
150
  const plugin = FloatingMenuPlugin({
110
- editor: attachToEditor,
111
- element: floatingMenuElement,
112
- pluginKey,
113
- updateDelay,
114
- resizeDelay,
115
- appendTo,
116
- shouldShow,
117
- options
151
+ ...floatingMenuPluginPropsRef.current,
152
+ editor: pluginEditor,
153
+ element: floatingMenuElement
118
154
  });
119
- attachToEditor.registerPlugin(plugin);
155
+ pluginEditor.registerPlugin(plugin);
156
+ const createdPluginKey = floatingMenuPluginPropsRef.current.pluginKey;
157
+ skipFirstUpdateRef.current = true;
158
+ setPluginInitialized(true);
120
159
  return () => {
121
- attachToEditor.unregisterPlugin(pluginKey);
160
+ setPluginInitialized(false);
161
+ pluginEditor.unregisterPlugin(createdPluginKey);
122
162
  window.requestAnimationFrame(() => {
123
163
  if (floatingMenuElement.parentNode) {
124
164
  floatingMenuElement.parentNode.removeChild(floatingMenuElement);
125
165
  }
126
166
  });
127
167
  };
128
- }, [editor, currentEditor, appendTo, pluginKey, shouldShow, options, updateDelay, resizeDelay]);
168
+ }, [pluginEditor]);
169
+ useEffect2(() => {
170
+ if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {
171
+ return;
172
+ }
173
+ if (skipFirstUpdateRef.current) {
174
+ skipFirstUpdateRef.current = false;
175
+ return;
176
+ }
177
+ pluginEditor.view.dispatch(
178
+ pluginEditor.state.tr.setMeta("floatingMenu", {
179
+ type: "updateOptions",
180
+ options: floatingMenuPluginPropsRef.current
181
+ })
182
+ );
183
+ }, [pluginInitialized, pluginEditor, updateDelay, resizeDelay, shouldShow, options, appendTo]);
129
184
  return createPortal2(/* @__PURE__ */ jsx2("div", { ...restProps, children }), menuEl.current);
130
185
  }
131
186
  );
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/menus/BubbleMenu.tsx","../../src/menus/FloatingMenu.tsx"],"sourcesContent":["import { type BubbleMenuPluginProps, BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'\nimport { useCurrentEditor } from '@tiptap/react'\nimport React, { useEffect, useRef } from 'react'\nimport { createPortal } from 'react-dom'\n\ntype Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>\n\nexport type BubbleMenuProps = Optional<Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>, 'editor'> &\n React.HTMLAttributes<HTMLDivElement>\n\nexport const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(\n (\n {\n pluginKey = 'bubbleMenu',\n editor,\n updateDelay,\n resizeDelay,\n appendTo,\n shouldShow = null,\n getReferencedVirtualElement,\n options,\n children,\n ...restProps\n },\n ref,\n ) => {\n const menuEl = useRef(document.createElement('div'))\n\n if (typeof ref === 'function') {\n ref(menuEl.current)\n } else if (ref) {\n ref.current = menuEl.current\n }\n\n const { editor: currentEditor } = useCurrentEditor()\n\n /**\n * The editor instance where the bubble menu plugin will be registered.\n */\n const pluginEditor = editor || currentEditor\n\n // Creating a useMemo would be more computationally expensive than just\n // re-creating this object on every render.\n const bubbleMenuPluginProps: Omit<BubbleMenuPluginProps, 'editor' | 'element'> = {\n updateDelay,\n resizeDelay,\n appendTo,\n pluginKey,\n shouldShow,\n getReferencedVirtualElement,\n options,\n }\n /**\n * The props for the bubble menu plugin. They are accessed inside a ref to\n * avoid running the useEffect hook and re-registering the plugin when the\n * props change.\n */\n const bubbleMenuPluginPropsRef = useRef(bubbleMenuPluginProps)\n bubbleMenuPluginPropsRef.current = bubbleMenuPluginProps\n\n useEffect(() => {\n if (pluginEditor?.isDestroyed) {\n return\n }\n\n if (!pluginEditor) {\n console.warn('BubbleMenu component is not rendered inside of an editor component or does not have editor prop.')\n return\n }\n\n const bubbleMenuElement = menuEl.current\n bubbleMenuElement.style.visibility = 'hidden'\n bubbleMenuElement.style.position = 'absolute'\n\n const plugin = BubbleMenuPlugin({\n ...bubbleMenuPluginPropsRef.current,\n editor: pluginEditor,\n element: bubbleMenuElement,\n })\n\n pluginEditor.registerPlugin(plugin)\n\n const createdPluginKey = bubbleMenuPluginPropsRef.current.pluginKey\n\n return () => {\n pluginEditor.unregisterPlugin(createdPluginKey)\n window.requestAnimationFrame(() => {\n if (bubbleMenuElement.parentNode) {\n bubbleMenuElement.parentNode.removeChild(bubbleMenuElement)\n }\n })\n }\n }, [pluginEditor])\n\n return createPortal(<div {...restProps}>{children}</div>, menuEl.current)\n },\n)\n","import type { FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'\nimport { FloatingMenuPlugin } from '@tiptap/extension-floating-menu'\nimport { useCurrentEditor } from '@tiptap/react'\nimport React, { useEffect, useRef } from 'react'\nimport { createPortal } from 'react-dom'\n\ntype Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>\n\nexport type FloatingMenuProps = Omit<Optional<FloatingMenuPluginProps, 'pluginKey'>, 'element' | 'editor'> & {\n editor: FloatingMenuPluginProps['editor'] | null\n options?: FloatingMenuPluginProps['options']\n} & React.HTMLAttributes<HTMLDivElement>\n\nexport const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(\n (\n {\n pluginKey = 'floatingMenu',\n editor,\n updateDelay,\n resizeDelay,\n appendTo,\n shouldShow = null,\n options,\n children,\n ...restProps\n },\n ref,\n ) => {\n const menuEl = useRef(document.createElement('div'))\n\n if (typeof ref === 'function') {\n ref(menuEl.current)\n } else if (ref) {\n ref.current = menuEl.current\n }\n\n const { editor: currentEditor } = useCurrentEditor()\n\n useEffect(() => {\n const floatingMenuElement = menuEl.current\n\n floatingMenuElement.style.visibility = 'hidden'\n floatingMenuElement.style.position = 'absolute'\n\n if (editor?.isDestroyed || (currentEditor as any)?.isDestroyed) {\n return\n }\n\n const attachToEditor = editor || currentEditor\n\n if (!attachToEditor) {\n console.warn(\n 'FloatingMenu component is not rendered inside of an editor component or does not have editor prop.',\n )\n return\n }\n\n const plugin = FloatingMenuPlugin({\n editor: attachToEditor,\n element: floatingMenuElement,\n pluginKey,\n updateDelay,\n resizeDelay,\n appendTo,\n shouldShow,\n options,\n })\n\n attachToEditor.registerPlugin(plugin)\n\n return () => {\n attachToEditor.unregisterPlugin(pluginKey)\n window.requestAnimationFrame(() => {\n if (floatingMenuElement.parentNode) {\n floatingMenuElement.parentNode.removeChild(floatingMenuElement)\n }\n })\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [editor, currentEditor, appendTo, pluginKey, shouldShow, options, updateDelay, resizeDelay])\n\n return createPortal(<div {...restProps}>{children}</div>, menuEl.current)\n },\n)\n"],"mappings":"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","names":["useCurrentEditor","React","useEffect","useRef","createPortal","jsx"]}
1
+ {"version":3,"sources":["../../src/menus/BubbleMenu.tsx","../../src/menus/FloatingMenu.tsx"],"sourcesContent":["import { type BubbleMenuPluginProps, BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'\nimport { useCurrentEditor } from '@tiptap/react'\nimport React, { useEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\n\ntype Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>\n\nexport type BubbleMenuProps = Optional<Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>, 'editor'> &\n React.HTMLAttributes<HTMLDivElement>\n\nexport const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(\n (\n {\n pluginKey = 'bubbleMenu',\n editor,\n updateDelay,\n resizeDelay,\n appendTo,\n shouldShow = null,\n getReferencedVirtualElement,\n options,\n children,\n ...restProps\n },\n ref,\n ) => {\n const menuEl = useRef(document.createElement('div'))\n\n if (typeof ref === 'function') {\n ref(menuEl.current)\n } else if (ref) {\n ref.current = menuEl.current\n }\n\n const { editor: currentEditor } = useCurrentEditor()\n\n /**\n * The editor instance where the bubble menu plugin will be registered.\n */\n const pluginEditor = editor || currentEditor\n\n // Creating a useMemo would be more computationally expensive than just\n // re-creating this object on every render.\n const bubbleMenuPluginProps: Omit<BubbleMenuPluginProps, 'editor' | 'element'> = {\n updateDelay,\n resizeDelay,\n appendTo,\n pluginKey,\n shouldShow,\n getReferencedVirtualElement,\n options,\n }\n /**\n * The props for the bubble menu plugin. They are accessed inside a ref to\n * avoid running the useEffect hook and re-registering the plugin when the\n * props change.\n */\n const bubbleMenuPluginPropsRef = useRef(bubbleMenuPluginProps)\n bubbleMenuPluginPropsRef.current = bubbleMenuPluginProps\n\n /**\n * Track whether the plugin has been initialized, so we only send updates\n * after the initial registration.\n */\n const [pluginInitialized, setPluginInitialized] = useState(false)\n\n /**\n * Track whether we need to skip the first options update dispatch.\n * This prevents unnecessary updates right after plugin initialization.\n */\n const skipFirstUpdateRef = useRef(true)\n\n useEffect(() => {\n if (pluginEditor?.isDestroyed) {\n return\n }\n\n if (!pluginEditor) {\n console.warn('BubbleMenu component is not rendered inside of an editor component or does not have editor prop.')\n return\n }\n\n const bubbleMenuElement = menuEl.current\n bubbleMenuElement.style.visibility = 'hidden'\n bubbleMenuElement.style.position = 'absolute'\n\n const plugin = BubbleMenuPlugin({\n ...bubbleMenuPluginPropsRef.current,\n editor: pluginEditor,\n element: bubbleMenuElement,\n })\n\n pluginEditor.registerPlugin(plugin)\n\n const createdPluginKey = bubbleMenuPluginPropsRef.current.pluginKey\n\n skipFirstUpdateRef.current = true\n setPluginInitialized(true)\n\n return () => {\n setPluginInitialized(false)\n pluginEditor.unregisterPlugin(createdPluginKey)\n window.requestAnimationFrame(() => {\n if (bubbleMenuElement.parentNode) {\n bubbleMenuElement.parentNode.removeChild(bubbleMenuElement)\n }\n })\n }\n }, [pluginEditor])\n\n /**\n * Update the plugin options when props change after the plugin has been initialized.\n * This allows dynamic updates to options like scrollTarget without re-registering the entire plugin.\n */\n useEffect(() => {\n if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {\n return\n }\n\n // Skip the first update right after initialization since the plugin was just created with these options\n if (skipFirstUpdateRef.current) {\n skipFirstUpdateRef.current = false\n return\n }\n\n pluginEditor.view.dispatch(\n pluginEditor.state.tr.setMeta('bubbleMenu', {\n type: 'updateOptions',\n options: bubbleMenuPluginPropsRef.current,\n }),\n )\n }, [\n pluginInitialized,\n pluginEditor,\n updateDelay,\n resizeDelay,\n shouldShow,\n options,\n appendTo,\n getReferencedVirtualElement,\n ])\n\n return createPortal(<div {...restProps}>{children}</div>, menuEl.current)\n },\n)\n","import type { FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'\nimport { FloatingMenuPlugin } from '@tiptap/extension-floating-menu'\nimport { useCurrentEditor } from '@tiptap/react'\nimport React, { useEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\n\ntype Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>\n\nexport type FloatingMenuProps = Omit<Optional<FloatingMenuPluginProps, 'pluginKey'>, 'element' | 'editor'> & {\n editor: FloatingMenuPluginProps['editor'] | null\n options?: FloatingMenuPluginProps['options']\n} & React.HTMLAttributes<HTMLDivElement>\n\nexport const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(\n (\n {\n pluginKey = 'floatingMenu',\n editor,\n updateDelay,\n resizeDelay,\n appendTo,\n shouldShow = null,\n options,\n children,\n ...restProps\n },\n ref,\n ) => {\n const menuEl = useRef(document.createElement('div'))\n\n if (typeof ref === 'function') {\n ref(menuEl.current)\n } else if (ref) {\n ref.current = menuEl.current\n }\n\n const { editor: currentEditor } = useCurrentEditor()\n\n /**\n * The editor instance where the floating menu plugin will be registered.\n */\n const pluginEditor = editor || currentEditor\n\n // Creating a useMemo would be more computationally expensive than just\n // re-creating this object on every render.\n const floatingMenuPluginProps: Omit<FloatingMenuPluginProps, 'editor' | 'element'> = {\n updateDelay,\n resizeDelay,\n appendTo,\n pluginKey,\n shouldShow,\n options,\n }\n\n /**\n * The props for the floating menu plugin. They are accessed inside a ref to\n * avoid running the useEffect hook and re-registering the plugin when the\n * props change.\n */\n const floatingMenuPluginPropsRef = useRef(floatingMenuPluginProps)\n floatingMenuPluginPropsRef.current = floatingMenuPluginProps\n\n /**\n * Track whether the plugin has been initialized, so we only send updates\n * after the initial registration.\n */\n const [pluginInitialized, setPluginInitialized] = useState(false)\n\n /**\n * Track whether we need to skip the first options update dispatch.\n * This prevents unnecessary updates right after plugin initialization.\n */\n const skipFirstUpdateRef = useRef(true)\n\n useEffect(() => {\n if (pluginEditor?.isDestroyed) {\n return\n }\n\n if (!pluginEditor) {\n console.warn(\n 'FloatingMenu component is not rendered inside of an editor component or does not have editor prop.',\n )\n return\n }\n\n const floatingMenuElement = menuEl.current\n floatingMenuElement.style.visibility = 'hidden'\n floatingMenuElement.style.position = 'absolute'\n\n const plugin = FloatingMenuPlugin({\n ...floatingMenuPluginPropsRef.current,\n editor: pluginEditor,\n element: floatingMenuElement,\n })\n\n pluginEditor.registerPlugin(plugin)\n\n const createdPluginKey = floatingMenuPluginPropsRef.current.pluginKey\n\n skipFirstUpdateRef.current = true\n setPluginInitialized(true)\n\n return () => {\n setPluginInitialized(false)\n pluginEditor.unregisterPlugin(createdPluginKey)\n window.requestAnimationFrame(() => {\n if (floatingMenuElement.parentNode) {\n floatingMenuElement.parentNode.removeChild(floatingMenuElement)\n }\n })\n }\n }, [pluginEditor])\n\n /**\n * Update the plugin options when props change after the plugin has been initialized.\n * This allows dynamic updates to options like scrollTarget without re-registering the entire plugin.\n */\n useEffect(() => {\n if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {\n return\n }\n\n // Skip the first update right after initialization since the plugin was just created with these options\n if (skipFirstUpdateRef.current) {\n skipFirstUpdateRef.current = false\n return\n }\n\n pluginEditor.view.dispatch(\n pluginEditor.state.tr.setMeta('floatingMenu', {\n type: 'updateOptions',\n options: floatingMenuPluginPropsRef.current,\n }),\n )\n }, [pluginInitialized, pluginEditor, updateDelay, resizeDelay, shouldShow, options, appendTo])\n\n return createPortal(<div {...restProps}>{children}</div>, menuEl.current)\n },\n)\n"],"mappings":"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","names":["useCurrentEditor","React","useEffect","useRef","useState","createPortal","jsx"]}
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.17.1",
4
+ "version": "3.19.0",
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.17.1",
52
- "@tiptap/pm": "^3.17.1"
51
+ "@tiptap/core": "^3.19.0",
52
+ "@tiptap/pm": "^3.19.0"
53
53
  },
54
54
  "optionalDependencies": {
55
- "@tiptap/extension-bubble-menu": "^3.17.1",
56
- "@tiptap/extension-floating-menu": "^3.17.1"
55
+ "@tiptap/extension-bubble-menu": "^3.19.0",
56
+ "@tiptap/extension-floating-menu": "^3.19.0"
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/pm": "^3.17.1",
64
- "@tiptap/core": "^3.17.1"
63
+ "@tiptap/core": "^3.19.0",
64
+ "@tiptap/pm": "^3.19.0"
65
65
  },
66
66
  "repository": {
67
67
  "type": "git",
@@ -100,6 +100,31 @@ export class ReactNodeView<
100
100
  }
101
101
  }
102
102
 
103
+ private cachedExtensionWithSyncedStorage: NodeViewRendererProps['extension'] | null = null
104
+
105
+ /**
106
+ * Returns a proxy of the extension that redirects storage access to the editor's mutable storage.
107
+ * This preserves the original prototype chain (instanceof checks, methods like configure/extend work).
108
+ * Cached to avoid proxy creation on every update.
109
+ */
110
+ get extensionWithSyncedStorage(): NodeViewRendererProps['extension'] {
111
+ if (!this.cachedExtensionWithSyncedStorage) {
112
+ const editor = this.editor
113
+ const extension = this.extension
114
+
115
+ this.cachedExtensionWithSyncedStorage = new Proxy(extension, {
116
+ get(target, prop, receiver) {
117
+ if (prop === 'storage') {
118
+ return editor.storage[extension.name as keyof typeof editor.storage] ?? {}
119
+ }
120
+ return Reflect.get(target, prop, receiver)
121
+ },
122
+ })
123
+ }
124
+
125
+ return this.cachedExtensionWithSyncedStorage
126
+ }
127
+
103
128
  /**
104
129
  * Setup the React component.
105
130
  * Called on initialization.
@@ -112,7 +137,7 @@ export class ReactNodeView<
112
137
  innerDecorations: this.innerDecorations,
113
138
  view: this.view,
114
139
  selected: false,
115
- extension: this.extension,
140
+ extension: this.extensionWithSyncedStorage,
116
141
  HTMLAttributes: this.HTMLAttributes,
117
142
  getPos: () => this.getPos(),
118
143
  updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
@@ -266,7 +291,8 @@ export class ReactNodeView<
266
291
  newDecorations: decorations,
267
292
  oldInnerDecorations,
268
293
  innerDecorations,
269
- updateProps: () => rerenderComponent({ node, decorations, innerDecorations }),
294
+ updateProps: () =>
295
+ rerenderComponent({ node, decorations, innerDecorations, extension: this.extensionWithSyncedStorage }),
270
296
  })
271
297
  }
272
298
 
@@ -278,7 +304,7 @@ export class ReactNodeView<
278
304
  this.decorations = decorations
279
305
  this.innerDecorations = innerDecorations
280
306
 
281
- rerenderComponent({ node, decorations, innerDecorations })
307
+ rerenderComponent({ node, decorations, innerDecorations, extension: this.extensionWithSyncedStorage })
282
308
 
283
309
  return true
284
310
  }
package/src/Tiptap.tsx ADDED
@@ -0,0 +1,224 @@
1
+ import type { ReactNode } from 'react'
2
+ import { createContext, useContext, useMemo } from 'react'
3
+
4
+ import { EditorContext } from './Context.js'
5
+ import type { Editor, EditorContentProps, EditorStateSnapshot } from './index.js'
6
+ import { EditorContent, useEditorState } from './index.js'
7
+
8
+ /**
9
+ * The shape of the React context used by the `<Tiptap />` components.
10
+ *
11
+ * The editor instance is always available when using the default `useEditor`
12
+ * configuration. For SSR scenarios where `immediatelyRender: false` is used,
13
+ * consider using the legacy `EditorProvider` pattern instead.
14
+ */
15
+ export type TiptapContextType = {
16
+ /** The Tiptap editor instance. */
17
+ editor: Editor
18
+ }
19
+
20
+ /**
21
+ * React context that stores the current editor instance.
22
+ *
23
+ * Use `useTiptap()` to read from this context in child components.
24
+ */
25
+ export const TiptapContext = createContext<TiptapContextType>({
26
+ get editor(): Editor {
27
+ throw new Error('useTiptap must be used within a <Tiptap> provider')
28
+ },
29
+ })
30
+
31
+ TiptapContext.displayName = 'TiptapContext'
32
+
33
+ /**
34
+ * Hook to read the Tiptap context and access the editor instance.
35
+ *
36
+ * This is a small convenience wrapper around `useContext(TiptapContext)`.
37
+ * The editor is always available when used within a `<Tiptap>` provider.
38
+ *
39
+ * @returns The current `TiptapContextType` value from the provider.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * import { useTiptap } from '@tiptap/react'
44
+ *
45
+ * function Toolbar() {
46
+ * const { editor } = useTiptap()
47
+ *
48
+ * return (
49
+ * <button onClick={() => editor.chain().focus().toggleBold().run()}>
50
+ * Bold
51
+ * </button>
52
+ * )
53
+ * }
54
+ * ```
55
+ */
56
+ export const useTiptap = () => useContext(TiptapContext)
57
+
58
+ /**
59
+ * Select a slice of the editor state using the context-provided editor.
60
+ *
61
+ * This is a thin wrapper around `useEditorState` that reads the `editor`
62
+ * instance from `useTiptap()` so callers don't have to pass it manually.
63
+ *
64
+ * @typeParam TSelectorResult - The type returned by the selector.
65
+ * @param selector - Function that receives the editor state snapshot and
66
+ * returns the piece of state you want to subscribe to.
67
+ * @param equalityFn - Optional function to compare previous/next selected
68
+ * values and avoid unnecessary updates.
69
+ * @returns The selected slice of the editor state.
70
+ *
71
+ * @example
72
+ * ```tsx
73
+ * function WordCount() {
74
+ * const wordCount = useTiptapState(state => {
75
+ * const text = state.editor.state.doc.textContent
76
+ * return text.split(/\s+/).filter(Boolean).length
77
+ * })
78
+ *
79
+ * return <span>{wordCount} words</span>
80
+ * }
81
+ * ```
82
+ */
83
+ export function useTiptapState<TSelectorResult>(
84
+ selector: (context: EditorStateSnapshot<Editor>) => TSelectorResult,
85
+ equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean,
86
+ ) {
87
+ const { editor } = useTiptap()
88
+
89
+ return useEditorState({
90
+ editor,
91
+ selector,
92
+ equalityFn,
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Props for the `Tiptap` root/provider component.
98
+ */
99
+ export type TiptapWrapperProps = {
100
+ /**
101
+ * The editor instance to provide to child components.
102
+ * Use `useEditor()` to create this instance.
103
+ */
104
+ editor?: Editor
105
+
106
+ /**
107
+ * @deprecated Use `editor` instead. Will be removed in the next major version.
108
+ */
109
+ instance?: Editor
110
+
111
+ children: ReactNode
112
+ }
113
+
114
+ /**
115
+ * Top-level provider component that makes the editor instance available via
116
+ * React context to all child components.
117
+ *
118
+ * This component also provides backwards compatibility with the legacy
119
+ * `EditorContext`, so components using `useCurrentEditor()` will work
120
+ * inside a `<Tiptap>` provider.
121
+ *
122
+ * @param props - Component props.
123
+ * @returns A context provider element wrapping `children`.
124
+ *
125
+ * @example
126
+ * ```tsx
127
+ * import { Tiptap, useEditor } from '@tiptap/react'
128
+ *
129
+ * function App() {
130
+ * const editor = useEditor({ extensions: [...] })
131
+ *
132
+ * return (
133
+ * <Tiptap editor={editor}>
134
+ * <Toolbar />
135
+ * <Tiptap.Content />
136
+ * </Tiptap>
137
+ * )
138
+ * }
139
+ * ```
140
+ */
141
+ export function TiptapWrapper({ editor, instance, children }: TiptapWrapperProps) {
142
+ const resolvedEditor = editor ?? instance
143
+
144
+ if (!resolvedEditor) {
145
+ throw new Error('Tiptap: An editor instance is required. Pass a non-null `editor` prop.')
146
+ }
147
+
148
+ const tiptapContextValue = useMemo<TiptapContextType>(() => ({ editor: resolvedEditor }), [resolvedEditor])
149
+
150
+ // Provide backwards compatibility with the legacy EditorContext
151
+ // so components using useCurrentEditor() work inside <Tiptap>
152
+ const legacyContextValue = useMemo(() => ({ editor: resolvedEditor }), [resolvedEditor])
153
+
154
+ return (
155
+ <EditorContext.Provider value={legacyContextValue}>
156
+ <TiptapContext.Provider value={tiptapContextValue}>{children}</TiptapContext.Provider>
157
+ </EditorContext.Provider>
158
+ )
159
+ }
160
+
161
+ TiptapWrapper.displayName = 'Tiptap'
162
+
163
+ /**
164
+ * Convenience component that renders `EditorContent` using the context-provided
165
+ * editor instance. Use this instead of manually passing the `editor` prop.
166
+ *
167
+ * @param props - All `EditorContent` props except `editor` and `ref`.
168
+ * @returns An `EditorContent` element bound to the context editor.
169
+ *
170
+ * @example
171
+ * ```tsx
172
+ * // inside a Tiptap provider
173
+ * <Tiptap.Content className="editor" />
174
+ * ```
175
+ */
176
+ export function TiptapContent({ ...rest }: Omit<EditorContentProps, 'editor' | 'ref'>) {
177
+ const { editor } = useTiptap()
178
+
179
+ return <EditorContent editor={editor} {...rest} />
180
+ }
181
+
182
+ TiptapContent.displayName = 'Tiptap.Content'
183
+
184
+ /**
185
+ * Root `Tiptap` component. Use it as the provider for all child components.
186
+ *
187
+ * The exported object includes the `Content` subcomponent for rendering the
188
+ * editor content area.
189
+ *
190
+ * This component provides both the new `TiptapContext` (accessed via `useTiptap()`)
191
+ * and the legacy `EditorContext` (accessed via `useCurrentEditor()`) for
192
+ * backwards compatibility.
193
+ *
194
+ * For bubble menus and floating menus, import them separately from
195
+ * `@tiptap/react/menus` to keep floating-ui as an optional dependency.
196
+ *
197
+ * @example
198
+ * ```tsx
199
+ * import { Tiptap, useEditor } from '@tiptap/react'
200
+ * import { BubbleMenu } from '@tiptap/react/menus'
201
+ *
202
+ * function App() {
203
+ * const editor = useEditor({ extensions: [...] })
204
+ *
205
+ * return (
206
+ * <Tiptap editor={editor}>
207
+ * <Tiptap.Content />
208
+ * <BubbleMenu>
209
+ * <button onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
210
+ * </BubbleMenu>
211
+ * </Tiptap>
212
+ * )
213
+ * }
214
+ * ```
215
+ */
216
+ export const Tiptap = Object.assign(TiptapWrapper, {
217
+ /**
218
+ * The Tiptap Content component that renders the EditorContent with the editor instance from the context.
219
+ * @see TiptapContent
220
+ */
221
+ Content: TiptapContent,
222
+ })
223
+
224
+ export default Tiptap
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from './NodeViewWrapper.js'
5
5
  export * from './ReactMarkViewRenderer.js'
6
6
  export * from './ReactNodeViewRenderer.js'
7
7
  export * from './ReactRenderer.js'
8
+ export * from './Tiptap.js'
8
9
  export * from './types.js'
9
10
  export * from './useEditor.js'
10
11
  export * from './useEditorState.js'
@@ -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
  )