@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,205 @@
1
+ import type { Editor } from '@tiptap/core';
2
+ import { type FloatingMenuPluginProps, FloatingMenuPlugin } from '@tiptap/extension-floating-menu';
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 FloatingMenuProps = Omit<
18
+ Optional<FloatingMenuPluginProps, 'pluginKey'>,
19
+ 'element' | 'editor'
20
+ > & {
21
+ editor: FloatingMenuPluginProps['editor'] | null;
22
+ options?: FloatingMenuPluginProps['options'];
23
+ } & MenuElementProps;
24
+
25
+ const FLOATING_MENU_SLOT = Symbol.for('@octanejs/tiptap:menus:FloatingMenu');
26
+
27
+ function areFloatingMenuPluginPropsEqual(
28
+ previous: Omit<FloatingMenuPluginProps, 'editor' | 'element'>,
29
+ next: Omit<FloatingMenuPluginProps, 'editor' | 'element'>,
30
+ ): boolean {
31
+ return (
32
+ previous.pluginKey === next.pluginKey &&
33
+ previous.updateDelay === next.updateDelay &&
34
+ previous.resizeDelay === next.resizeDelay &&
35
+ previous.appendTo === next.appendTo &&
36
+ previous.shouldShow === next.shouldShow &&
37
+ previous.options === next.options
38
+ );
39
+ }
40
+
41
+ function scheduleElementRemoval(
42
+ element: HTMLDivElement,
43
+ registrationRef: { current: number },
44
+ registration: number,
45
+ ): void {
46
+ const remove = () => {
47
+ if (registrationRef.current === registration && element.parentNode) {
48
+ element.parentNode.removeChild(element);
49
+ }
50
+ };
51
+
52
+ if (typeof window.requestAnimationFrame === 'function') {
53
+ window.requestAnimationFrame(remove);
54
+ } else {
55
+ setTimeout(remove, 0);
56
+ }
57
+ }
58
+
59
+ export function FloatingMenu(props: FloatingMenuProps): unknown {
60
+ const {
61
+ pluginKey,
62
+ editor,
63
+ updateDelay,
64
+ resizeDelay,
65
+ appendTo,
66
+ shouldShow = null,
67
+ options,
68
+ children,
69
+ ref,
70
+ ...restProps
71
+ } = props;
72
+ const [menuElement, setMenuElement] = useState<HTMLDivElement | null>(
73
+ null,
74
+ subSlot(FLOATING_MENU_SLOT, 'element'),
75
+ );
76
+ const resolvedPluginKeyRef = useRef<PluginKey | string | null>(
77
+ null,
78
+ subSlot(FLOATING_MENU_SLOT, 'plugin-key'),
79
+ );
80
+ const registrationRef = useRef(0, subSlot(FLOATING_MENU_SLOT, 'registration'));
81
+ const [pluginInitialized, setPluginInitialized] = useState(
82
+ false,
83
+ subSlot(FLOATING_MENU_SLOT, 'initialized'),
84
+ );
85
+ const lastAppliedPluginPropsRef = useRef<Omit<
86
+ FloatingMenuPluginProps,
87
+ 'editor' | 'element'
88
+ > | null>(null, subSlot(FLOATING_MENU_SLOT, 'last-applied-plugin-props'));
89
+
90
+ if (resolvedPluginKeyRef.current === null) {
91
+ resolvedPluginKeyRef.current = getAutoPluginKey(pluginKey, 'floatingMenu');
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(FLOATING_MENU_SLOT, 'mount'),
105
+ );
106
+
107
+ useMenuElementProps(menuElement, restProps, subSlot(FLOATING_MENU_SLOT, 'element-props'));
108
+ useMenuElementRef(menuElement, ref, subSlot(FLOATING_MENU_SLOT, 'element-ref'));
109
+
110
+ const { editor: currentEditor } = useCurrentEditor();
111
+ const pluginEditor: Editor | null = editor || currentEditor;
112
+ const floatingMenuPluginProps: Omit<FloatingMenuPluginProps, 'editor' | 'element'> = {
113
+ updateDelay,
114
+ resizeDelay,
115
+ appendTo,
116
+ pluginKey: resolvedPluginKey,
117
+ shouldShow,
118
+ options,
119
+ };
120
+ const floatingMenuPluginPropsRef = useRef(
121
+ floatingMenuPluginProps,
122
+ subSlot(FLOATING_MENU_SLOT, 'plugin-props'),
123
+ );
124
+ floatingMenuPluginPropsRef.current = floatingMenuPluginProps;
125
+
126
+ useEffect(
127
+ () => {
128
+ if (!menuElement || pluginEditor?.isDestroyed) {
129
+ return;
130
+ }
131
+
132
+ if (!pluginEditor) {
133
+ console.warn(
134
+ 'FloatingMenu component is not rendered inside of an editor component or does not have editor prop.',
135
+ );
136
+ return;
137
+ }
138
+
139
+ menuElement.style.visibility = 'hidden';
140
+ menuElement.style.position = 'absolute';
141
+
142
+ const registeredPluginProps = floatingMenuPluginPropsRef.current;
143
+ const plugin = FloatingMenuPlugin({
144
+ ...registeredPluginProps,
145
+ editor: pluginEditor,
146
+ element: menuElement,
147
+ });
148
+ lastAppliedPluginPropsRef.current = registeredPluginProps;
149
+ pluginEditor.registerPlugin(plugin);
150
+
151
+ const createdPluginKey = registeredPluginProps.pluginKey;
152
+ const registration = registrationRef.current + 1;
153
+ registrationRef.current = registration;
154
+ setPluginInitialized(true);
155
+
156
+ return () => {
157
+ setPluginInitialized(false);
158
+ lastAppliedPluginPropsRef.current = null;
159
+ pluginEditor.unregisterPlugin(createdPluginKey);
160
+ scheduleElementRemoval(menuElement, registrationRef, registration);
161
+ };
162
+ },
163
+ [menuElement, pluginEditor],
164
+ subSlot(FLOATING_MENU_SLOT, 'plugin'),
165
+ );
166
+
167
+ useEffect(
168
+ () => {
169
+ if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {
170
+ return;
171
+ }
172
+
173
+ const nextPluginProps = floatingMenuPluginPropsRef.current;
174
+ const lastAppliedPluginProps = lastAppliedPluginPropsRef.current;
175
+
176
+ if (
177
+ lastAppliedPluginProps &&
178
+ areFloatingMenuPluginPropsEqual(lastAppliedPluginProps, nextPluginProps)
179
+ ) {
180
+ return;
181
+ }
182
+
183
+ pluginEditor.view.dispatch(
184
+ pluginEditor.state.tr.setMeta(resolvedPluginKey, {
185
+ type: 'updateOptions',
186
+ options: nextPluginProps,
187
+ }),
188
+ );
189
+ lastAppliedPluginPropsRef.current = nextPluginProps;
190
+ },
191
+ [
192
+ pluginInitialized,
193
+ pluginEditor,
194
+ updateDelay,
195
+ resizeDelay,
196
+ shouldShow,
197
+ options,
198
+ appendTo,
199
+ resolvedPluginKey,
200
+ ],
201
+ subSlot(FLOATING_MENU_SLOT, 'options'),
202
+ );
203
+
204
+ return menuElement ? createPortal(children, menuElement) : null;
205
+ }
@@ -0,0 +1,8 @@
1
+ import { PluginKey } from '@tiptap/pm/state';
2
+
3
+ export function getAutoPluginKey(
4
+ pluginKey: PluginKey | string | undefined,
5
+ defaultName: string,
6
+ ): PluginKey | string {
7
+ return pluginKey ?? new PluginKey(defaultName);
8
+ }
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+
3
+ export { BubbleMenu } from './BubbleMenu';
4
+ export type { BubbleMenuProps } from './BubbleMenu';
5
+ export { FloatingMenu } from './FloatingMenu';
6
+ export type { FloatingMenuProps } from './FloatingMenu';
@@ -0,0 +1,441 @@
1
+ import { normalizeClass, useEffect, useLayoutEffect, useRef } from 'octane';
2
+
3
+ import { splitSlot, subSlot } from '../internal';
4
+
5
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
6
+
7
+ type MenuStyleValue = string | number | boolean | null | undefined;
8
+ type MenuStyle = Record<string, MenuStyleValue>;
9
+ type MenuEventHandler<E extends Event = Event> = (event: E) => unknown;
10
+
11
+ export type MenuElementRef<T> =
12
+ | { current: T | null }
13
+ | ((value: T | null) => void | (() => void))
14
+ | null;
15
+
16
+ export type MenuElementProps = {
17
+ children?: unknown;
18
+ ref?: MenuElementRef<HTMLDivElement>;
19
+ class?: unknown;
20
+ className?: unknown;
21
+ style?: MenuStyle;
22
+ tabIndex?: number;
23
+ onBlur?: MenuEventHandler<FocusEvent>;
24
+ onBlurCapture?: MenuEventHandler<FocusEvent>;
25
+ onChange?: MenuEventHandler;
26
+ onChangeCapture?: MenuEventHandler;
27
+ onClick?: MenuEventHandler<MouseEvent>;
28
+ onClickCapture?: MenuEventHandler<MouseEvent>;
29
+ onDoubleClick?: MenuEventHandler<MouseEvent>;
30
+ onDoubleClickCapture?: MenuEventHandler<MouseEvent>;
31
+ onFocus?: MenuEventHandler<FocusEvent>;
32
+ onFocusCapture?: MenuEventHandler<FocusEvent>;
33
+ onInput?: MenuEventHandler<InputEvent>;
34
+ onInputCapture?: MenuEventHandler<InputEvent>;
35
+ onKeyDown?: MenuEventHandler<KeyboardEvent>;
36
+ onKeyDownCapture?: MenuEventHandler<KeyboardEvent>;
37
+ onKeyUp?: MenuEventHandler<KeyboardEvent>;
38
+ onKeyUpCapture?: MenuEventHandler<KeyboardEvent>;
39
+ onMouseEnter?: MenuEventHandler<MouseEvent>;
40
+ onMouseLeave?: MenuEventHandler<MouseEvent>;
41
+ onPointerDown?: MenuEventHandler<PointerEvent>;
42
+ onPointerDownCapture?: MenuEventHandler<PointerEvent>;
43
+ [key: string]: unknown;
44
+ };
45
+
46
+ type MenuNativeListener = (event: Event) => void;
47
+ type MenuEventListenerOptions = {
48
+ capture?: boolean;
49
+ };
50
+
51
+ type EventListenerEntry = {
52
+ eventName: string;
53
+ listener: MenuNativeListener;
54
+ options?: MenuEventListenerOptions;
55
+ };
56
+
57
+ const PLUGIN_MANAGED_STYLE_PROPERTIES = new Set([
58
+ 'left',
59
+ 'opacity',
60
+ 'position',
61
+ 'top',
62
+ 'visibility',
63
+ 'width',
64
+ ]);
65
+
66
+ const UNITLESS_STYLE_PROPERTIES = new Set([
67
+ 'animationIterationCount',
68
+ 'aspectRatio',
69
+ 'borderImageOutset',
70
+ 'borderImageSlice',
71
+ 'borderImageWidth',
72
+ 'columnCount',
73
+ 'columns',
74
+ 'fillOpacity',
75
+ 'flex',
76
+ 'flexGrow',
77
+ 'flexShrink',
78
+ 'fontWeight',
79
+ 'gridArea',
80
+ 'gridColumn',
81
+ 'gridColumnEnd',
82
+ 'gridColumnStart',
83
+ 'gridRow',
84
+ 'gridRowEnd',
85
+ 'gridRowStart',
86
+ 'lineClamp',
87
+ 'lineHeight',
88
+ 'opacity',
89
+ 'order',
90
+ 'orphans',
91
+ 'scale',
92
+ 'stopOpacity',
93
+ 'strokeDasharray',
94
+ 'strokeDashoffset',
95
+ 'strokeMiterlimit',
96
+ 'strokeOpacity',
97
+ 'strokeWidth',
98
+ 'tabSize',
99
+ 'widows',
100
+ 'zIndex',
101
+ 'zoom',
102
+ ]);
103
+
104
+ const ATTRIBUTE_EXCLUSIONS = new Set(['children', 'class', 'className', 'ref', 'style']);
105
+ const DIRECT_PROPERTY_KEYS = new Set(['tabIndex']);
106
+ const FORWARDED_ATTRIBUTE_KEYS = new Set([
107
+ 'accessKey',
108
+ 'autoCapitalize',
109
+ 'contentEditable',
110
+ 'contextMenu',
111
+ 'dir',
112
+ 'draggable',
113
+ 'enterKeyHint',
114
+ 'hidden',
115
+ 'id',
116
+ 'lang',
117
+ 'nonce',
118
+ 'role',
119
+ 'slot',
120
+ 'spellCheck',
121
+ 'tabIndex',
122
+ 'title',
123
+ 'translate',
124
+ ]);
125
+
126
+ const SPECIAL_EVENT_NAMES: Record<string, string> = {
127
+ Blur: 'focusout',
128
+ DoubleClick: 'dblclick',
129
+ Focus: 'focusin',
130
+ MouseEnter: 'mouseenter',
131
+ MouseLeave: 'mouseleave',
132
+ };
133
+
134
+ function isEventProp(key: string, value: unknown): value is MenuEventHandler {
135
+ return /^on[A-Z]/.test(key) && typeof value === 'function';
136
+ }
137
+
138
+ function isForwardedAttributeKey(key: string): boolean {
139
+ return key.startsWith('aria-') || key.startsWith('data-') || FORWARDED_ATTRIBUTE_KEYS.has(key);
140
+ }
141
+
142
+ function toStylePropertyName(key: string): string {
143
+ if (key.startsWith('--')) {
144
+ return key;
145
+ }
146
+
147
+ return key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
148
+ }
149
+
150
+ function toEventConfig(key: string): {
151
+ eventName: string;
152
+ options: MenuEventListenerOptions | undefined;
153
+ } {
154
+ const useCapture = key.endsWith('Capture');
155
+ const baseKey = useCapture ? key.slice(0, -7) : key;
156
+ const eventNameKey = baseKey.slice(2);
157
+ const eventName = SPECIAL_EVENT_NAMES[eventNameKey] ?? eventNameKey.toLowerCase();
158
+
159
+ return {
160
+ eventName,
161
+ options: useCapture ? { capture: true } : undefined,
162
+ };
163
+ }
164
+
165
+ function isDirectPropertyKey(key: string): boolean {
166
+ return DIRECT_PROPERTY_KEYS.has(key);
167
+ }
168
+
169
+ function setDirectProperty(element: HTMLDivElement, key: string, value: unknown): void {
170
+ if (key === 'tabIndex') {
171
+ element.tabIndex = Number(value);
172
+ return;
173
+ }
174
+
175
+ (element as unknown as Record<string, unknown>)[key] = value;
176
+ }
177
+
178
+ function clearDirectProperty(element: HTMLDivElement, key: string): void {
179
+ if (key === 'tabIndex') {
180
+ element.removeAttribute('tabindex');
181
+ return;
182
+ }
183
+
184
+ const propertyValue = (element as unknown as Record<string, unknown>)[key];
185
+
186
+ if (typeof propertyValue === 'boolean') {
187
+ (element as unknown as Record<string, unknown>)[key] = false;
188
+ return;
189
+ }
190
+
191
+ if (typeof propertyValue === 'number') {
192
+ (element as unknown as Record<string, unknown>)[key] = 0;
193
+ return;
194
+ }
195
+
196
+ (element as unknown as Record<string, unknown>)[key] = '';
197
+ }
198
+
199
+ function toStyleValue(styleName: string, value: string | number): string {
200
+ if (
201
+ typeof value !== 'number' ||
202
+ value === 0 ||
203
+ styleName.startsWith('--') ||
204
+ UNITLESS_STYLE_PROPERTIES.has(styleName)
205
+ ) {
206
+ return String(value);
207
+ }
208
+
209
+ return `${value}px`;
210
+ }
211
+
212
+ function removeStyleProperty(element: HTMLDivElement, styleName: string): void {
213
+ if (PLUGIN_MANAGED_STYLE_PROPERTIES.has(styleName)) {
214
+ return;
215
+ }
216
+
217
+ element.style.removeProperty(toStylePropertyName(styleName));
218
+ }
219
+
220
+ function applyStyleProperty(
221
+ element: HTMLDivElement,
222
+ styleName: string,
223
+ value: string | number,
224
+ ): void {
225
+ if (PLUGIN_MANAGED_STYLE_PROPERTIES.has(styleName)) {
226
+ return;
227
+ }
228
+
229
+ element.style.setProperty(toStylePropertyName(styleName), toStyleValue(styleName, value));
230
+ }
231
+
232
+ function syncAttributes(
233
+ element: HTMLDivElement,
234
+ previousProps: MenuElementProps,
235
+ nextProps: MenuElementProps,
236
+ ): void {
237
+ const allKeys = new Set([...Object.keys(previousProps), ...Object.keys(nextProps)]);
238
+
239
+ allKeys.forEach((key) => {
240
+ if (
241
+ ATTRIBUTE_EXCLUSIONS.has(key) ||
242
+ !isForwardedAttributeKey(key) ||
243
+ isEventProp(key, previousProps[key]) ||
244
+ isEventProp(key, nextProps[key])
245
+ ) {
246
+ return;
247
+ }
248
+
249
+ const previousValue = previousProps[key];
250
+ const nextValue = nextProps[key];
251
+
252
+ if (previousValue === nextValue) {
253
+ return;
254
+ }
255
+
256
+ if (nextValue == null || nextValue === false) {
257
+ if (isDirectPropertyKey(key)) {
258
+ clearDirectProperty(element, key);
259
+ }
260
+
261
+ element.removeAttribute(key);
262
+ return;
263
+ }
264
+
265
+ if (nextValue === true) {
266
+ if (isDirectPropertyKey(key)) {
267
+ setDirectProperty(element, key, true);
268
+ }
269
+
270
+ element.setAttribute(key, '');
271
+ return;
272
+ }
273
+
274
+ if (isDirectPropertyKey(key)) {
275
+ setDirectProperty(element, key, nextValue);
276
+ return;
277
+ }
278
+
279
+ element.setAttribute(key, String(nextValue));
280
+ });
281
+ }
282
+
283
+ function syncClassName(
284
+ element: HTMLDivElement,
285
+ previousProps: MenuElementProps,
286
+ nextProps: MenuElementProps,
287
+ ): void {
288
+ const previousClassName = normalizeClass([previousProps.class, previousProps.className]);
289
+ const nextClassName = normalizeClass([nextProps.class, nextProps.className]);
290
+
291
+ if (previousClassName === nextClassName) {
292
+ return;
293
+ }
294
+
295
+ if (nextClassName) {
296
+ element.className = nextClassName;
297
+ return;
298
+ }
299
+
300
+ element.removeAttribute('class');
301
+ }
302
+
303
+ function syncStyles(
304
+ element: HTMLDivElement,
305
+ previousStyle: MenuStyle | undefined,
306
+ nextStyle: MenuStyle | undefined,
307
+ ): void {
308
+ const oldStyle = previousStyle ?? {};
309
+ const newStyle = nextStyle ?? {};
310
+ const allStyleNames = new Set([...Object.keys(oldStyle), ...Object.keys(newStyle)]);
311
+
312
+ allStyleNames.forEach((styleName) => {
313
+ const previousValue = oldStyle[styleName];
314
+ const nextValue = newStyle[styleName];
315
+
316
+ if (previousValue === nextValue) {
317
+ return;
318
+ }
319
+
320
+ if (nextValue == null || typeof nextValue === 'boolean') {
321
+ removeStyleProperty(element, styleName);
322
+ return;
323
+ }
324
+
325
+ applyStyleProperty(element, styleName, nextValue);
326
+ });
327
+ }
328
+
329
+ function syncEventListeners(
330
+ element: HTMLDivElement,
331
+ previousListeners: EventListenerEntry[],
332
+ nextProps: MenuElementProps,
333
+ ): EventListenerEntry[] {
334
+ previousListeners.forEach(({ eventName, listener, options }) => {
335
+ element.removeEventListener(eventName, listener, options);
336
+ });
337
+
338
+ const nextListeners: EventListenerEntry[] = [];
339
+
340
+ Object.entries(nextProps).forEach(([key, value]) => {
341
+ if (!isEventProp(key, value)) {
342
+ return;
343
+ }
344
+
345
+ const { eventName, options } = toEventConfig(key);
346
+ const listener: MenuNativeListener = (event) => {
347
+ // Octane delegates portal-child handlers from this same target. Native
348
+ // stopPropagation() does not suppress later listeners on the current node,
349
+ // so honor the delegated child's cancellation before invoking the menu's
350
+ // logical ancestor handler. Direct events on the menu itself still fire.
351
+ if (!options?.capture && event.cancelBubble && event.target !== element) {
352
+ return;
353
+ }
354
+
355
+ value(event);
356
+ };
357
+
358
+ element.addEventListener(eventName, listener, options);
359
+ nextListeners.push({ eventName, listener, options });
360
+ });
361
+
362
+ return nextListeners;
363
+ }
364
+
365
+ export function useMenuElementProps(
366
+ element: HTMLDivElement | null,
367
+ props: MenuElementProps,
368
+ ...args: unknown[]
369
+ ): void {
370
+ const [, slot] = splitSlot(args);
371
+ const previousPropsRef = useRef<MenuElementProps>({}, subSlot(slot, 'menu-props:previous'));
372
+ const previousElementRef = useRef<HTMLDivElement | null>(
373
+ null,
374
+ subSlot(slot, 'menu-props:element'),
375
+ );
376
+ const listenersRef = useRef<EventListenerEntry[]>([], subSlot(slot, 'menu-props:listeners'));
377
+
378
+ useIsomorphicLayoutEffect(
379
+ () => {
380
+ if (!element) {
381
+ return;
382
+ }
383
+
384
+ const previousProps = previousElementRef.current === element ? previousPropsRef.current : {};
385
+
386
+ syncClassName(element, previousProps, props);
387
+ syncStyles(element, previousProps.style, props.style);
388
+ syncAttributes(element, previousProps, props);
389
+ listenersRef.current = syncEventListeners(element, listenersRef.current, props);
390
+ previousPropsRef.current = props;
391
+ previousElementRef.current = element;
392
+
393
+ return () => {
394
+ listenersRef.current.forEach(({ eventName, listener, options }) => {
395
+ element.removeEventListener(eventName, listener, options);
396
+ });
397
+ listenersRef.current = [];
398
+ };
399
+ },
400
+ [element, props],
401
+ subSlot(slot, 'menu-props:effect'),
402
+ );
403
+ }
404
+
405
+ export function useMenuElementRef(
406
+ element: HTMLDivElement | null,
407
+ ref: MenuElementRef<HTMLDivElement> | undefined,
408
+ ...args: unknown[]
409
+ ): void {
410
+ const [, slot] = splitSlot(args);
411
+
412
+ useIsomorphicLayoutEffect(
413
+ () => {
414
+ if (!element || !ref) {
415
+ return;
416
+ }
417
+
418
+ if (typeof ref === 'function') {
419
+ const cleanup = ref(element);
420
+
421
+ return () => {
422
+ if (typeof cleanup === 'function') {
423
+ cleanup();
424
+ } else {
425
+ ref(null);
426
+ }
427
+ };
428
+ }
429
+
430
+ ref.current = element;
431
+
432
+ return () => {
433
+ if (ref.current === element) {
434
+ ref.current = null;
435
+ }
436
+ };
437
+ },
438
+ [element, ref],
439
+ subSlot(slot, 'menu-ref:effect'),
440
+ );
441
+ }
package/src/useEditor.ts CHANGED
@@ -205,7 +205,20 @@ class EditorInstanceManager {
205
205
  const currentInstanceId = this.instanceId;
206
206
  const currentEditor = this.editor;
207
207
 
208
+ // Re-arming replaces any pending destruction timer so two can never race.
209
+ clearTimeout(this.scheduledDestructionTimeout);
208
210
  this.scheduledDestructionTimeout = setTimeout(() => {
211
+ this.scheduledDestructionTimeout = undefined;
212
+
213
+ // This timer outlives the owning component by a tick, so it can fire
214
+ // after the DOM environment the editor lives in is gone (e.g. a test
215
+ // runner disposing jsdom between files). Touching the editor then would
216
+ // crash inside prosemirror-view's `window` access, and there is nothing
217
+ // left to release anyway.
218
+ if (typeof window === 'undefined') {
219
+ return;
220
+ }
221
+
209
222
  if (this.isComponentMounted && this.instanceId === currentInstanceId) {
210
223
  currentEditor?.setOptions(this.options.current);
211
224
  return;
@@ -0,0 +1,32 @@
1
+ import { createContext, createElement, useContext } from 'octane';
2
+
3
+ export interface ReactNodeViewContextProps {
4
+ onDragStart?: (event: DragEvent) => void;
5
+ nodeViewContentRef?: (element: HTMLElement | null) => void;
6
+ /** Static-rendered content exposed through NodeViewContent. */
7
+ nodeViewContentChildren?: unknown;
8
+ }
9
+
10
+ export const ReactNodeViewContext = createContext<ReactNodeViewContextProps>({
11
+ onDragStart: () => {},
12
+ nodeViewContentChildren: undefined,
13
+ nodeViewContentRef: () => {},
14
+ });
15
+
16
+ export interface ReactNodeViewContentProviderProps {
17
+ children: unknown;
18
+ content: unknown;
19
+ }
20
+
21
+ /** Supply static node content without creating a live ProseMirror content DOM. */
22
+ export function ReactNodeViewContentProvider({
23
+ children,
24
+ content,
25
+ }: ReactNodeViewContentProviderProps): unknown {
26
+ return createElement(ReactNodeViewContext.Provider, {
27
+ value: { nodeViewContentChildren: content },
28
+ children,
29
+ });
30
+ }
31
+
32
+ export const useReactNodeView = (): ReactNodeViewContextProps => useContext(ReactNodeViewContext);