@octanejs/tiptap 0.0.1 → 0.0.4

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.
@@ -0,0 +1,353 @@
1
+ import type {
2
+ DecorationWithType,
3
+ Editor,
4
+ NodeViewProps as CoreNodeViewProps,
5
+ NodeViewRenderer,
6
+ NodeViewRendererOptions,
7
+ NodeViewRendererProps,
8
+ } from '@tiptap/core';
9
+ import { getRenderedAttributes, isNodeViewSelected, NodeView } from '@tiptap/core';
10
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
11
+ import type {
12
+ Decoration,
13
+ DecorationSource,
14
+ NodeView as ProseMirrorNodeView,
15
+ } from '@tiptap/pm/view';
16
+ import { createElement, memo, type ComponentBody } from 'octane';
17
+
18
+ import type { EditorWithContentComponent } from './Editor';
19
+ import { ReactRenderer } from './ReactRenderer';
20
+ import { ReactNodeViewContext, type ReactNodeViewContextProps } from './useReactNodeView';
21
+
22
+ export type ReactNodeViewRef<T> = {
23
+ current: T | null;
24
+ };
25
+
26
+ export type ReactNodeViewProps<T = HTMLElement> = CoreNodeViewProps & {
27
+ ref: ReactNodeViewRef<T>;
28
+ };
29
+
30
+ export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions {
31
+ /** Decide whether and when a custom update should republish component props. */
32
+ update:
33
+ | ((props: {
34
+ oldNode: ProseMirrorNode;
35
+ oldDecorations: readonly Decoration[];
36
+ oldInnerDecorations: DecorationSource;
37
+ newNode: ProseMirrorNode;
38
+ newDecorations: readonly Decoration[];
39
+ innerDecorations: DecorationSource;
40
+ updateProps: () => void;
41
+ }) => boolean)
42
+ | null;
43
+ /** Wrapper element tag. */
44
+ as?: string;
45
+ /** Additional wrapper classes. */
46
+ className?: string;
47
+ /** Static wrapper attributes or attributes derived from the current node. */
48
+ attrs?:
49
+ | Record<string, string>
50
+ | ((props: {
51
+ node: ProseMirrorNode;
52
+ HTMLAttributes: Record<string, any>;
53
+ }) => Record<string, string>);
54
+ }
55
+
56
+ export class ReactNodeView<
57
+ T = HTMLElement,
58
+ Component extends ComponentBody<ReactNodeViewProps<T>> = ComponentBody<ReactNodeViewProps<T>>,
59
+ NodeEditor extends Editor = Editor,
60
+ Options extends ReactNodeViewRendererOptions = ReactNodeViewRendererOptions,
61
+ > extends NodeView<Component, NodeEditor, Options> {
62
+ // Core NodeView invokes mount() from its constructor. These fields are therefore
63
+ // assigned before derived class field initializers run and must remain type-only,
64
+ // otherwise native class-field initialization would overwrite the mounted renderer.
65
+ declare renderer: ReactRenderer<unknown, ReactNodeViewProps<T>>;
66
+ declare contentDOMElement: HTMLElement | null;
67
+ selectionRafId: number | null = null;
68
+ declare private currentPos: number | undefined;
69
+ declare private cachedExtensionWithSyncedStorage: NodeViewRendererProps['extension'] | null;
70
+
71
+ private handlePositionUpdate = () => {
72
+ const newPos = this.getPos();
73
+ if (typeof newPos !== 'number' || newPos === this.currentPos) {
74
+ return;
75
+ }
76
+
77
+ this.currentPos = newPos;
78
+ this.renderer.updateProps({ getPos: () => this.getPos() });
79
+
80
+ if (typeof this.options.attrs === 'function') {
81
+ this.updateElementAttributes();
82
+ }
83
+ };
84
+
85
+ constructor(component: Component, props: NodeViewRendererProps, options?: Partial<Options>) {
86
+ super(component, props, options);
87
+
88
+ if (!this.node.isLeaf) {
89
+ this.contentDOMElement = document.createElement(
90
+ this.options.contentDOMElementTag || (this.node.isInline ? 'span' : 'div'),
91
+ );
92
+ this.contentDOMElement.dataset.nodeViewContentReact = '';
93
+ this.contentDOMElement.dataset.nodeViewWrapper = '';
94
+ this.contentDOMElement.style.whiteSpace = 'inherit';
95
+
96
+ // Covers the synchronous portal path. The context ref installed in mount()
97
+ // covers a portal that commits after this constructor returns.
98
+ const contentTarget = this.dom.querySelector('[data-node-view-content]');
99
+ contentTarget?.appendChild(this.contentDOMElement);
100
+ }
101
+
102
+ if (this.options.trackNodeViewPosition) {
103
+ this.editor.on('update', this.handlePositionUpdate);
104
+ }
105
+ }
106
+
107
+ /** Expose the editor's current mutable extension storage through the original extension. */
108
+ get extensionWithSyncedStorage(): NodeViewRendererProps['extension'] {
109
+ if (!this.cachedExtensionWithSyncedStorage) {
110
+ const editor = this.editor;
111
+ const extension = this.extension;
112
+
113
+ this.cachedExtensionWithSyncedStorage = new Proxy(extension, {
114
+ get(target, property, receiver) {
115
+ if (property === 'storage') {
116
+ return editor.storage[extension.name as keyof typeof editor.storage] ?? {};
117
+ }
118
+ return Reflect.get(target, property, receiver);
119
+ },
120
+ });
121
+ }
122
+
123
+ return this.cachedExtensionWithSyncedStorage;
124
+ }
125
+
126
+ /** Create the context-wrapped component renderer. Called by core NodeView. */
127
+ mount(): void {
128
+ const componentProps = {
129
+ editor: this.editor,
130
+ node: this.node,
131
+ decorations: this.decorations as DecorationWithType[],
132
+ innerDecorations: this.innerDecorations,
133
+ view: this.view,
134
+ selected: false,
135
+ extension: this.extensionWithSyncedStorage,
136
+ HTMLAttributes: this.HTMLAttributes,
137
+ getPos: () => this.getPos(),
138
+ updateAttributes: (attributes: Record<string, any> = {}) => this.updateAttributes(attributes),
139
+ deleteNode: () => this.deleteNode(),
140
+ ref: { current: null } as ReactNodeViewRef<T>,
141
+ } satisfies ReactNodeViewProps<T>;
142
+
143
+ if (!(this.component as any).displayName) {
144
+ (this.component as any).displayName =
145
+ this.extension.name.charAt(0).toUpperCase() + this.extension.name.substring(1);
146
+ }
147
+
148
+ const context: ReactNodeViewContextProps = {
149
+ onDragStart: this.onDragStart.bind(this),
150
+ nodeViewContentRef: (element) => {
151
+ if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {
152
+ element.removeAttribute('data-node-view-wrapper');
153
+ element.appendChild(this.contentDOMElement);
154
+ }
155
+ },
156
+ };
157
+ const Component = this.component;
158
+ const providerBody: ComponentBody<ReactNodeViewProps<T>> = (props) =>
159
+ createElement(ReactNodeViewContext.Provider, {
160
+ value: context,
161
+ children: createElement(Component, props),
162
+ });
163
+ const ReactNodeViewProvider = memo(providerBody);
164
+ (ReactNodeViewProvider as any).displayName = 'ReactNodeView';
165
+
166
+ const as = this.options.as || (this.node.isInline ? 'span' : 'div');
167
+ const { className = '' } = this.options;
168
+
169
+ this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this);
170
+ this.renderer = new ReactRenderer(ReactNodeViewProvider, {
171
+ editor: this.editor,
172
+ props: componentProps,
173
+ as,
174
+ className: `node-${this.node.type.name} ${className}`.trim(),
175
+ });
176
+
177
+ this.editor.on('selectionUpdate', this.handleSelectionUpdate);
178
+ this.updateElementAttributes();
179
+ this.currentPos = this.getPos();
180
+ }
181
+
182
+ get dom(): HTMLElement {
183
+ if (
184
+ this.renderer.element.firstElementChild &&
185
+ !this.renderer.element.firstElementChild.hasAttribute('data-node-view-wrapper')
186
+ ) {
187
+ throw new Error('Please use the NodeViewWrapper component for your node view.');
188
+ }
189
+
190
+ return this.renderer.element;
191
+ }
192
+
193
+ get contentDOM(): HTMLElement | null {
194
+ return this.node.isLeaf ? null : this.contentDOMElement;
195
+ }
196
+
197
+ handleSelectionUpdate(): void {
198
+ if (this.selectionRafId) {
199
+ cancelAnimationFrame(this.selectionRafId);
200
+ this.selectionRafId = null;
201
+ }
202
+
203
+ this.selectionRafId = requestAnimationFrame(() => {
204
+ this.selectionRafId = null;
205
+ const pos = this.currentPos;
206
+ if (typeof pos !== 'number') {
207
+ return;
208
+ }
209
+
210
+ const selected = isNodeViewSelected({
211
+ selection: this.editor.state.selection,
212
+ pos,
213
+ nodeSize: this.node.nodeSize,
214
+ selectedOnTextSelection: this.options.selectedOnTextSelection,
215
+ });
216
+
217
+ if (selected) {
218
+ if (!this.renderer.props.selected) {
219
+ this.selectNode();
220
+ }
221
+ } else if (this.renderer.props.selected) {
222
+ this.deselectNode();
223
+ }
224
+ });
225
+ }
226
+
227
+ update(
228
+ node: ProseMirrorNode,
229
+ decorations: readonly Decoration[],
230
+ innerDecorations: DecorationSource,
231
+ ): boolean {
232
+ const rerenderComponent = (props?: Record<string, any>) => {
233
+ this.renderer.updateProps(props);
234
+ if (typeof this.options.attrs === 'function') {
235
+ this.updateElementAttributes();
236
+ }
237
+ };
238
+
239
+ if (node.type !== this.node.type) {
240
+ return false;
241
+ }
242
+
243
+ if (typeof this.options.update === 'function') {
244
+ const oldNode = this.node;
245
+ const oldDecorations = this.decorations;
246
+ const oldInnerDecorations = this.innerDecorations;
247
+
248
+ this.node = node;
249
+ this.decorations = decorations;
250
+ this.innerDecorations = innerDecorations;
251
+ this.currentPos = this.getPos();
252
+
253
+ return this.options.update({
254
+ oldNode,
255
+ oldDecorations,
256
+ newNode: node,
257
+ newDecorations: decorations,
258
+ oldInnerDecorations,
259
+ innerDecorations,
260
+ updateProps: () =>
261
+ rerenderComponent({
262
+ node,
263
+ decorations,
264
+ innerDecorations,
265
+ extension: this.extensionWithSyncedStorage,
266
+ }),
267
+ });
268
+ }
269
+
270
+ if (node === this.node) {
271
+ this.node = node;
272
+ this.decorations = decorations;
273
+ this.innerDecorations = innerDecorations;
274
+ return true;
275
+ }
276
+
277
+ this.node = node;
278
+ this.decorations = decorations;
279
+ this.innerDecorations = innerDecorations;
280
+ this.currentPos = this.getPos();
281
+
282
+ const extraProps: Record<string, any> = {
283
+ node,
284
+ decorations,
285
+ innerDecorations,
286
+ extension: this.extensionWithSyncedStorage,
287
+ };
288
+
289
+ if (this.options.trackNodeViewPosition) {
290
+ extraProps.getPos = () => this.getPos();
291
+ }
292
+
293
+ rerenderComponent(extraProps);
294
+ return true;
295
+ }
296
+
297
+ selectNode(): void {
298
+ this.renderer.updateProps({ selected: true });
299
+ this.renderer.element.classList.add('ProseMirror-selectednode');
300
+ }
301
+
302
+ deselectNode(): void {
303
+ this.renderer.updateProps({ selected: false });
304
+ this.renderer.element.classList.remove('ProseMirror-selectednode');
305
+ }
306
+
307
+ destroy(): void {
308
+ this.renderer.destroy();
309
+ this.editor.off('selectionUpdate', this.handleSelectionUpdate);
310
+
311
+ if (this.options.trackNodeViewPosition) {
312
+ this.editor.off('update', this.handlePositionUpdate);
313
+ }
314
+
315
+ this.contentDOMElement = null;
316
+ if (this.selectionRafId) {
317
+ cancelAnimationFrame(this.selectionRafId);
318
+ this.selectionRafId = null;
319
+ }
320
+ }
321
+
322
+ updateElementAttributes(): void {
323
+ if (!this.options.attrs) {
324
+ return;
325
+ }
326
+
327
+ let attributes: Record<string, string>;
328
+ if (typeof this.options.attrs === 'function') {
329
+ attributes = this.options.attrs({
330
+ node: this.node,
331
+ HTMLAttributes: getRenderedAttributes(this.node, this.editor.extensionManager.attributes),
332
+ });
333
+ } else {
334
+ attributes = this.options.attrs;
335
+ }
336
+
337
+ this.renderer.updateAttributes(attributes);
338
+ }
339
+ }
340
+
341
+ /** Create a TipTap node-view renderer backed by an Octane portal. */
342
+ export function ReactNodeViewRenderer<T = HTMLElement>(
343
+ component: ComponentBody<ReactNodeViewProps<T>>,
344
+ options?: Partial<ReactNodeViewRendererOptions>,
345
+ ): NodeViewRenderer {
346
+ return (props) => {
347
+ if (!(props.editor as EditorWithContentComponent).contentComponent) {
348
+ return {} as ProseMirrorNodeView;
349
+ }
350
+
351
+ return new ReactNodeView<T>(component, props, options);
352
+ };
353
+ }
@@ -0,0 +1,128 @@
1
+ import type { Editor } from '@tiptap/core';
2
+ import { createElement, flushSync, type ComponentBody, type ElementDescriptor } from 'octane';
3
+
4
+ import type { EditorWithContentComponent } from './Editor';
5
+
6
+ export interface ReactRendererOptions {
7
+ /** The editor instance that owns this renderer. */
8
+ editor: Editor;
9
+ /** Initial component props. */
10
+ props?: Record<string, any>;
11
+ /** Wrapper element tag. @default 'div' */
12
+ as?: string;
13
+ /** Additional wrapper classes. */
14
+ className?: string;
15
+ }
16
+
17
+ type RendererProps<R> = Record<string, any> & {
18
+ ref?: ((value: R | null) => void) | { current: R | null } | null;
19
+ };
20
+
21
+ /**
22
+ * Renders an Octane component into a ProseMirror-owned wrapper through the
23
+ * EditorContent portal registry. The React-prefixed name is retained for
24
+ * compatibility with TipTap extensions that only swap their binding import.
25
+ */
26
+ export class ReactRenderer<R = unknown, P extends Record<string, any> = object> {
27
+ id: string;
28
+ editor: EditorWithContentComponent;
29
+ component: ComponentBody<P>;
30
+ element: HTMLElement;
31
+ props: P;
32
+ reactElement!: ElementDescriptor<P>;
33
+ ref: R | null = null;
34
+ destroyed = false;
35
+
36
+ constructor(
37
+ component: ComponentBody<P>,
38
+ { editor, props = {}, as = 'div', className = '' }: ReactRendererOptions,
39
+ ) {
40
+ this.id = Math.floor(Math.random() * 0xffffffff).toString();
41
+ this.component = component;
42
+ this.editor = editor as EditorWithContentComponent;
43
+ this.props = props as P;
44
+ this.element = document.createElement(as);
45
+ this.element.classList.add('react-renderer');
46
+
47
+ if (className) {
48
+ this.element.classList.add(...className.split(' '));
49
+ }
50
+
51
+ if (this.editor.isEditorContentInitialized) {
52
+ flushSync(() => this.render());
53
+ } else {
54
+ queueMicrotask(() => {
55
+ if (!this.destroyed) {
56
+ this.render();
57
+ }
58
+ });
59
+ }
60
+ }
61
+
62
+ /** Publish the current component descriptor to EditorContent. */
63
+ render(): void {
64
+ if (this.destroyed) {
65
+ return;
66
+ }
67
+
68
+ const elementProps = { ...this.props } as P & RendererProps<R>;
69
+
70
+ // Octane follows React 19's ordinary-ref-prop model. Components that receive
71
+ // a caller-owned ref keep it; otherwise expose their forwarded value here.
72
+ if (!elementProps.ref) {
73
+ elementProps.ref = (value: R | null) => {
74
+ this.ref = value;
75
+ };
76
+ }
77
+
78
+ this.reactElement = createElement(this.component, elementProps as P);
79
+ this.editor.contentComponent?.setRenderer(this.id, this);
80
+ }
81
+
82
+ /** Merge changed props and publish a fresh descriptor. */
83
+ updateProps(props: Record<string, any> = {}): void {
84
+ if (this.destroyed) {
85
+ return;
86
+ }
87
+
88
+ let changed = false;
89
+ const keys = Object.keys(props);
90
+
91
+ for (let index = 0; index < keys.length; index += 1) {
92
+ const key = keys[index];
93
+ if (props[key] !== this.props[key]) {
94
+ changed = true;
95
+ break;
96
+ }
97
+ }
98
+
99
+ if (!changed) {
100
+ return;
101
+ }
102
+
103
+ this.props = {
104
+ ...this.props,
105
+ ...props,
106
+ };
107
+ this.render();
108
+ }
109
+
110
+ /** Unregister the portal and release any ProseMirror-mounted wrapper. */
111
+ destroy(): void {
112
+ this.destroyed = true;
113
+ this.editor.contentComponent?.removeRenderer(this.id);
114
+
115
+ try {
116
+ this.element.parentNode?.removeChild(this.element);
117
+ } catch {
118
+ // ProseMirror may already have detached the wrapper.
119
+ }
120
+ }
121
+
122
+ /** Apply attributes to the ProseMirror-facing wrapper element. */
123
+ updateAttributes(attributes: Record<string, string>): void {
124
+ Object.keys(attributes).forEach((key) => {
125
+ this.element.setAttribute(key, attributes[key]);
126
+ });
127
+ }
128
+ }
package/src/index.ts CHANGED
@@ -3,7 +3,13 @@
3
3
  export * from './Context';
4
4
  export * from './Context.tsrx';
5
5
  export * from './EditorContent.tsrx';
6
+ export * from './NodeViewContent';
7
+ export * from './NodeViewWrapper';
8
+ export * from './ReactMarkViewRenderer';
9
+ export * from './ReactNodeViewRenderer';
10
+ export * from './ReactRenderer';
6
11
  export * from './Tiptap.tsrx';
7
12
  export * from './useEditor';
8
13
  export * from './useEditorState';
14
+ export * from './useReactNodeView';
9
15
  export * from '@tiptap/core';
@@ -0,0 +1,207 @@
1
+ import { type BubbleMenuPluginProps, BubbleMenuPlugin } from '@tiptap/extension-bubble-menu';
2
+ import type { Editor } from '@tiptap/core';
3
+ import type { PluginKey } from '@tiptap/pm/state';
4
+ import { createPortal, useEffect, useLayoutEffect, useRef, useState } from 'octane';
5
+
6
+ import { useCurrentEditor } from '../Context';
7
+ import { subSlot } from '../internal';
8
+ import { getAutoPluginKey } from './getAutoPluginKey';
9
+ import {
10
+ type MenuElementProps,
11
+ useMenuElementProps,
12
+ useMenuElementRef,
13
+ } from './useMenuElementProps';
14
+
15
+ type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
16
+
17
+ export type BubbleMenuProps = Optional<
18
+ Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>,
19
+ 'editor'
20
+ > &
21
+ MenuElementProps;
22
+
23
+ const BUBBLE_MENU_SLOT = Symbol.for('@octanejs/tiptap:menus:BubbleMenu');
24
+
25
+ function areBubbleMenuPluginPropsEqual(
26
+ previous: Omit<BubbleMenuPluginProps, 'editor' | 'element'>,
27
+ next: Omit<BubbleMenuPluginProps, 'editor' | 'element'>,
28
+ ): boolean {
29
+ return (
30
+ previous.pluginKey === next.pluginKey &&
31
+ previous.updateDelay === next.updateDelay &&
32
+ previous.resizeDelay === next.resizeDelay &&
33
+ previous.appendTo === next.appendTo &&
34
+ previous.shouldShow === next.shouldShow &&
35
+ previous.getReferencedVirtualElement === next.getReferencedVirtualElement &&
36
+ previous.options === next.options
37
+ );
38
+ }
39
+
40
+ function scheduleElementRemoval(
41
+ element: HTMLDivElement,
42
+ registrationRef: { current: number },
43
+ registration: number,
44
+ ): void {
45
+ const remove = () => {
46
+ if (registrationRef.current === registration && element.parentNode) {
47
+ element.parentNode.removeChild(element);
48
+ }
49
+ };
50
+
51
+ if (typeof window.requestAnimationFrame === 'function') {
52
+ window.requestAnimationFrame(remove);
53
+ } else {
54
+ setTimeout(remove, 0);
55
+ }
56
+ }
57
+
58
+ export function BubbleMenu(props: BubbleMenuProps): unknown {
59
+ const {
60
+ pluginKey,
61
+ editor,
62
+ updateDelay,
63
+ resizeDelay,
64
+ appendTo,
65
+ shouldShow = null,
66
+ getReferencedVirtualElement,
67
+ options,
68
+ children,
69
+ ref,
70
+ ...restProps
71
+ } = props;
72
+ const [menuElement, setMenuElement] = useState<HTMLDivElement | null>(
73
+ null,
74
+ subSlot(BUBBLE_MENU_SLOT, 'element'),
75
+ );
76
+ const resolvedPluginKeyRef = useRef<PluginKey | string | null>(
77
+ null,
78
+ subSlot(BUBBLE_MENU_SLOT, 'plugin-key'),
79
+ );
80
+ const registrationRef = useRef(0, subSlot(BUBBLE_MENU_SLOT, 'registration'));
81
+ const [pluginInitialized, setPluginInitialized] = useState(
82
+ false,
83
+ subSlot(BUBBLE_MENU_SLOT, 'initialized'),
84
+ );
85
+ const lastAppliedPluginPropsRef = useRef<Omit<
86
+ BubbleMenuPluginProps,
87
+ 'editor' | 'element'
88
+ > | null>(null, subSlot(BUBBLE_MENU_SLOT, 'last-applied-plugin-props'));
89
+
90
+ if (resolvedPluginKeyRef.current === null) {
91
+ resolvedPluginKeyRef.current = getAutoPluginKey(pluginKey, 'bubbleMenu');
92
+ }
93
+ const resolvedPluginKey = resolvedPluginKeyRef.current;
94
+
95
+ useLayoutEffect(
96
+ () => {
97
+ if (typeof document === 'undefined') {
98
+ return;
99
+ }
100
+
101
+ setMenuElement(document.createElement('div'));
102
+ },
103
+ [],
104
+ subSlot(BUBBLE_MENU_SLOT, 'mount'),
105
+ );
106
+
107
+ useMenuElementProps(menuElement, restProps, subSlot(BUBBLE_MENU_SLOT, 'element-props'));
108
+ useMenuElementRef(menuElement, ref, subSlot(BUBBLE_MENU_SLOT, 'element-ref'));
109
+
110
+ const { editor: currentEditor } = useCurrentEditor();
111
+ const pluginEditor: Editor | null = editor || currentEditor;
112
+ const bubbleMenuPluginProps: Omit<BubbleMenuPluginProps, 'editor' | 'element'> = {
113
+ updateDelay,
114
+ resizeDelay,
115
+ appendTo,
116
+ pluginKey: resolvedPluginKey,
117
+ shouldShow,
118
+ getReferencedVirtualElement,
119
+ options,
120
+ };
121
+ const bubbleMenuPluginPropsRef = useRef(
122
+ bubbleMenuPluginProps,
123
+ subSlot(BUBBLE_MENU_SLOT, 'plugin-props'),
124
+ );
125
+ bubbleMenuPluginPropsRef.current = bubbleMenuPluginProps;
126
+
127
+ useEffect(
128
+ () => {
129
+ if (!menuElement || pluginEditor?.isDestroyed) {
130
+ return;
131
+ }
132
+
133
+ if (!pluginEditor) {
134
+ console.warn(
135
+ 'BubbleMenu component is not rendered inside of an editor component or does not have editor prop.',
136
+ );
137
+ return;
138
+ }
139
+
140
+ menuElement.style.visibility = 'hidden';
141
+ menuElement.style.position = 'absolute';
142
+
143
+ const registeredPluginProps = bubbleMenuPluginPropsRef.current;
144
+ const plugin = BubbleMenuPlugin({
145
+ ...registeredPluginProps,
146
+ editor: pluginEditor,
147
+ element: menuElement,
148
+ });
149
+ lastAppliedPluginPropsRef.current = registeredPluginProps;
150
+ pluginEditor.registerPlugin(plugin);
151
+
152
+ const createdPluginKey = registeredPluginProps.pluginKey;
153
+ const registration = registrationRef.current + 1;
154
+ registrationRef.current = registration;
155
+ setPluginInitialized(true);
156
+
157
+ return () => {
158
+ setPluginInitialized(false);
159
+ lastAppliedPluginPropsRef.current = null;
160
+ pluginEditor.unregisterPlugin(createdPluginKey);
161
+ scheduleElementRemoval(menuElement, registrationRef, registration);
162
+ };
163
+ },
164
+ [menuElement, pluginEditor],
165
+ subSlot(BUBBLE_MENU_SLOT, 'plugin'),
166
+ );
167
+
168
+ useEffect(
169
+ () => {
170
+ if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {
171
+ return;
172
+ }
173
+
174
+ const nextPluginProps = bubbleMenuPluginPropsRef.current;
175
+ const lastAppliedPluginProps = lastAppliedPluginPropsRef.current;
176
+
177
+ if (
178
+ lastAppliedPluginProps &&
179
+ areBubbleMenuPluginPropsEqual(lastAppliedPluginProps, nextPluginProps)
180
+ ) {
181
+ return;
182
+ }
183
+
184
+ pluginEditor.view.dispatch(
185
+ pluginEditor.state.tr.setMeta(resolvedPluginKey, {
186
+ type: 'updateOptions',
187
+ options: nextPluginProps,
188
+ }),
189
+ );
190
+ lastAppliedPluginPropsRef.current = nextPluginProps;
191
+ },
192
+ [
193
+ pluginInitialized,
194
+ pluginEditor,
195
+ updateDelay,
196
+ resizeDelay,
197
+ shouldShow,
198
+ options,
199
+ appendTo,
200
+ getReferencedVirtualElement,
201
+ resolvedPluginKey,
202
+ ],
203
+ subSlot(BUBBLE_MENU_SLOT, 'options'),
204
+ );
205
+
206
+ return menuElement ? createPortal(children, menuElement) : null;
207
+ }