@tiptap/react 2.5.9 → 2.6.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,23 +1,19 @@
1
1
  import React, { ForwardedRef, HTMLProps } from 'react';
2
2
  import { Editor } from './Editor.js';
3
- import { ReactRenderer } from './ReactRenderer.js';
4
3
  export interface EditorContentProps extends HTMLProps<HTMLDivElement> {
5
4
  editor: Editor | null;
6
5
  innerRef?: ForwardedRef<HTMLDivElement | null>;
7
6
  }
8
- export interface EditorContentState {
9
- renderers: Record<string, ReactRenderer>;
10
- }
11
- export declare class PureEditorContent extends React.Component<EditorContentProps, EditorContentState> {
7
+ export declare class PureEditorContent extends React.Component<EditorContentProps, {
8
+ hasContentComponentInitialized: boolean;
9
+ }> {
12
10
  editorContentRef: React.RefObject<any>;
13
11
  initialized: boolean;
12
+ unsubscribeToContentComponent?: () => void;
14
13
  constructor(props: EditorContentProps);
15
14
  componentDidMount(): void;
16
15
  componentDidUpdate(): void;
17
16
  init(): void;
18
- maybeFlushSync(fn: () => void): void;
19
- setRenderer(id: string, renderer: ReactRenderer): void;
20
- removeRenderer(id: string): void;
21
17
  componentWillUnmount(): void;
22
18
  render(): React.JSX.Element;
23
19
  }
@@ -1,6 +1,5 @@
1
- import { EditorOptions } from '@tiptap/core';
1
+ import { type EditorOptions, Editor } from '@tiptap/core';
2
2
  import { DependencyList } from 'react';
3
- import { Editor } from './Editor.js';
4
3
  /**
5
4
  * The options for the `useEditor` hook.
6
5
  */
@@ -1,4 +1,4 @@
1
- import type { Editor } from './Editor.js';
1
+ import type { Editor } from '@tiptap/core';
2
2
  export type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> = {
3
3
  editor: TEditor;
4
4
  transactionNumber: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiptap/react",
3
3
  "description": "React components for tiptap",
4
- "version": "2.5.9",
4
+ "version": "2.6.0",
5
5
  "homepage": "https://tiptap.dev",
6
6
  "keywords": [
7
7
  "tiptap",
@@ -29,22 +29,22 @@
29
29
  "dist"
30
30
  ],
31
31
  "dependencies": {
32
- "@tiptap/extension-bubble-menu": "^2.5.9",
33
- "@tiptap/extension-floating-menu": "^2.5.9",
32
+ "@tiptap/extension-bubble-menu": "^2.6.0",
33
+ "@tiptap/extension-floating-menu": "^2.6.0",
34
34
  "@types/use-sync-external-store": "^0.0.6",
35
35
  "use-sync-external-store": "^1.2.2"
36
36
  },
37
37
  "devDependencies": {
38
- "@tiptap/core": "^2.5.9",
39
- "@tiptap/pm": "^2.5.9",
38
+ "@tiptap/core": "^2.6.0",
39
+ "@tiptap/pm": "^2.6.0",
40
40
  "@types/react": "^18.2.14",
41
41
  "@types/react-dom": "^18.2.6",
42
42
  "react": "^18.0.0",
43
43
  "react-dom": "^18.0.0"
44
44
  },
45
45
  "peerDependencies": {
46
- "@tiptap/core": "^2.5.9",
47
- "@tiptap/pm": "^2.5.9",
46
+ "@tiptap/core": "^2.6.0",
47
+ "@tiptap/pm": "^2.6.0",
48
48
  "react": "^17.0.0 || ^18.0.0",
49
49
  "react-dom": "^17.0.0 || ^18.0.0"
50
50
  },
package/src/Context.tsx CHANGED
@@ -1,6 +1,7 @@
1
+ import { Editor } from '@tiptap/core'
1
2
  import React, { createContext, ReactNode, useContext } from 'react'
2
3
 
3
- import { Editor } from './Editor.js'
4
+ import { Editor as ReactEditor } from './Editor.js'
4
5
  import { EditorContent } from './EditorContent.js'
5
6
  import { useEditor, UseEditorOptions } from './useEditor.js'
6
7
 
@@ -44,7 +45,7 @@ export function EditorProvider({
44
45
  {slotBefore}
45
46
  <EditorConsumer>
46
47
  {({ editor: currentEditor }) => (
47
- <EditorContent editor={currentEditor} />
48
+ <EditorContent editor={currentEditor as ReactEditor} />
48
49
  )}
49
50
  </EditorConsumer>
50
51
  {children}
package/src/Editor.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { Editor as CoreEditor } from '@tiptap/core'
2
2
  import React from 'react'
3
3
 
4
- import { EditorContentProps, EditorContentState } from './EditorContent.js'
5
4
  import { ReactRenderer } from './ReactRenderer.js'
6
5
 
7
- type ContentComponent = React.Component<EditorContentProps, EditorContentState> & {
6
+ type ContentComponent = {
8
7
  setRenderer(id: string, renderer: ReactRenderer): void;
9
8
  removeRenderer(id: string): void;
9
+ subscribe: (callback: () => void) => () => void;
10
+ getSnapshot: () => Record<string, React.ReactPortal>;
11
+ getServerSnapshot: () => Record<string, React.ReactPortal>;
10
12
  }
11
13
 
12
14
  export class Editor extends CoreEditor {
@@ -1,7 +1,8 @@
1
1
  import React, {
2
2
  ForwardedRef, forwardRef, HTMLProps, LegacyRef, MutableRefObject,
3
3
  } from 'react'
4
- import ReactDOM, { flushSync } from 'react-dom'
4
+ import ReactDOM from 'react-dom'
5
+ import { useSyncExternalStore } from 'use-sync-external-store/shim'
5
6
 
6
7
  import { Editor } from './Editor.js'
7
8
  import { ReactRenderer } from './ReactRenderer.js'
@@ -20,12 +21,23 @@ const mergeRefs = <T extends HTMLDivElement>(
20
21
  }
21
22
  }
22
23
 
23
- const Portals: React.FC<{ renderers: Record<string, ReactRenderer> }> = ({ renderers }) => {
24
+ /**
25
+ * This component renders all of the editor's node views.
26
+ */
27
+ const Portals: React.FC<{ contentComponent: Exclude<Editor['contentComponent'], null> }> = ({
28
+ contentComponent,
29
+ }) => {
30
+ // For performance reasons, we render the node view portals on state changes only
31
+ const renderers = useSyncExternalStore(
32
+ contentComponent.subscribe,
33
+ contentComponent.getSnapshot,
34
+ contentComponent.getServerSnapshot,
35
+ )
36
+
37
+ // This allows us to directly render the portals without any additional wrapper
24
38
  return (
25
39
  <>
26
- {Object.entries(renderers).map(([key, renderer]) => {
27
- return ReactDOM.createPortal(renderer.reactElement, renderer.element, key)
28
- })}
40
+ {Object.values(renderers)}
29
41
  </>
30
42
  )
31
43
  }
@@ -35,22 +47,67 @@ export interface EditorContentProps extends HTMLProps<HTMLDivElement> {
35
47
  innerRef?: ForwardedRef<HTMLDivElement | null>;
36
48
  }
37
49
 
38
- export interface EditorContentState {
39
- renderers: Record<string, ReactRenderer>;
50
+ function getInstance(): Exclude<Editor['contentComponent'], null> {
51
+ const subscribers = new Set<() => void>()
52
+ let renderers: Record<string, React.ReactPortal> = {}
53
+
54
+ return {
55
+ /**
56
+ * Subscribe to the editor instance's changes.
57
+ */
58
+ subscribe(callback: () => void) {
59
+ subscribers.add(callback)
60
+ return () => {
61
+ subscribers.delete(callback)
62
+ }
63
+ },
64
+ getSnapshot() {
65
+ return renderers
66
+ },
67
+ getServerSnapshot() {
68
+ return renderers
69
+ },
70
+ /**
71
+ * Adds a new NodeView Renderer to the editor.
72
+ */
73
+ setRenderer(id: string, renderer: ReactRenderer) {
74
+ renderers = {
75
+ ...renderers,
76
+ [id]: ReactDOM.createPortal(renderer.reactElement, renderer.element, id),
77
+ }
78
+
79
+ subscribers.forEach(subscriber => subscriber())
80
+ },
81
+ /**
82
+ * Removes a NodeView Renderer from the editor.
83
+ */
84
+ removeRenderer(id: string) {
85
+ const nextRenderers = { ...renderers }
86
+
87
+ delete nextRenderers[id]
88
+ renderers = nextRenderers
89
+ subscribers.forEach(subscriber => subscriber())
90
+ },
91
+ }
40
92
  }
41
93
 
42
- export class PureEditorContent extends React.Component<EditorContentProps, EditorContentState> {
94
+ export class PureEditorContent extends React.Component<
95
+ EditorContentProps,
96
+ { hasContentComponentInitialized: boolean }
97
+ > {
43
98
  editorContentRef: React.RefObject<any>
44
99
 
45
100
  initialized: boolean
46
101
 
102
+ unsubscribeToContentComponent?: () => void
103
+
47
104
  constructor(props: EditorContentProps) {
48
105
  super(props)
49
106
  this.editorContentRef = React.createRef()
50
107
  this.initialized = false
51
108
 
52
109
  this.state = {
53
- renderers: {},
110
+ hasContentComponentInitialized: Boolean(props.editor?.contentComponent),
54
111
  }
55
112
  }
56
113
 
@@ -78,7 +135,27 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
78
135
  element,
79
136
  })
80
137
 
81
- editor.contentComponent = this
138
+ editor.contentComponent = getInstance()
139
+
140
+ // Has the content component been initialized?
141
+ if (!this.state.hasContentComponentInitialized) {
142
+ // Subscribe to the content component
143
+ this.unsubscribeToContentComponent = editor.contentComponent.subscribe(() => {
144
+ this.setState(prevState => {
145
+ if (!prevState.hasContentComponentInitialized) {
146
+ return {
147
+ hasContentComponentInitialized: true,
148
+ }
149
+ }
150
+ return prevState
151
+ })
152
+
153
+ // Unsubscribe to previous content component
154
+ if (this.unsubscribeToContentComponent) {
155
+ this.unsubscribeToContentComponent()
156
+ }
157
+ })
158
+ }
82
159
 
83
160
  editor.createNodeViews()
84
161
 
@@ -86,41 +163,6 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
86
163
  }
87
164
  }
88
165
 
89
- maybeFlushSync(fn: () => void) {
90
- // Avoid calling flushSync until the editor is initialized.
91
- // Initialization happens during the componentDidMount or componentDidUpdate
92
- // lifecycle methods, and React doesn't allow calling flushSync from inside
93
- // a lifecycle method.
94
- if (this.initialized) {
95
- flushSync(fn)
96
- } else {
97
- fn()
98
- }
99
- }
100
-
101
- setRenderer(id: string, renderer: ReactRenderer) {
102
- this.maybeFlushSync(() => {
103
- this.setState(({ renderers }) => ({
104
- renderers: {
105
- ...renderers,
106
- [id]: renderer,
107
- },
108
- }))
109
- })
110
- }
111
-
112
- removeRenderer(id: string) {
113
- this.maybeFlushSync(() => {
114
- this.setState(({ renderers }) => {
115
- const nextRenderers = { ...renderers }
116
-
117
- delete nextRenderers[id]
118
-
119
- return { renderers: nextRenderers }
120
- })
121
- })
122
- }
123
-
124
166
  componentWillUnmount() {
125
167
  const { editor } = this.props
126
168
 
@@ -136,6 +178,10 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
136
178
  })
137
179
  }
138
180
 
181
+ if (this.unsubscribeToContentComponent) {
182
+ this.unsubscribeToContentComponent()
183
+ }
184
+
139
185
  editor.contentComponent = null
140
186
 
141
187
  if (!editor.options.element.firstChild) {
@@ -158,7 +204,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
158
204
  <>
159
205
  <div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
160
206
  {/* @ts-ignore */}
161
- <Portals renderers={this.state.renderers} />
207
+ {editor?.contentComponent && <Portals contentComponent={editor.contentComponent} />}
162
208
  </>
163
209
  )
164
210
  }
@@ -168,7 +214,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
168
214
  const EditorContentWithKey = forwardRef<HTMLDivElement, EditorContentProps>(
169
215
  (props: Omit<EditorContentProps, 'innerRef'>, ref) => {
170
216
  const key = React.useMemo(() => {
171
- return Math.floor(Math.random() * 0xFFFFFFFF).toString()
217
+ return Math.floor(Math.random() * 0xffffffff).toString()
172
218
  }, [props.editor])
173
219
 
174
220
  // Can't use JSX here because it conflicts with the type definition of Vue's JSX, so use createElement
@@ -12,6 +12,7 @@ export const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef(
12
12
  const Tag = props.as || 'div'
13
13
 
14
14
  return (
15
+ // @ts-ignore
15
16
  <Tag
16
17
  {...props}
17
18
  ref={ref}
@@ -58,25 +58,23 @@ class ReactNodeView extends NodeView<
58
58
  this.component.displayName = capitalizeFirstChar(this.extension.name)
59
59
  }
60
60
 
61
- const ReactNodeViewProvider: React.FunctionComponent = componentProps => {
62
- const Component = this.component
63
- const onDragStart = this.onDragStart.bind(this)
64
- const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {
65
- if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {
66
- element.appendChild(this.contentDOMElement)
67
- }
61
+ const onDragStart = this.onDragStart.bind(this)
62
+ const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {
63
+ if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {
64
+ element.appendChild(this.contentDOMElement)
68
65
  }
69
-
66
+ }
67
+ const context = { onDragStart, nodeViewContentRef }
68
+ const Component = this.component
69
+ // For performance reasons, we memoize the provider component
70
+ // And all of the things it requires are declared outside of the component, so it doesn't need to re-render
71
+ const ReactNodeViewProvider: React.FunctionComponent = React.memo(componentProps => {
70
72
  return (
71
- <>
72
- {/* @ts-ignore */}
73
- <ReactNodeViewContext.Provider value={{ onDragStart, nodeViewContentRef }}>
74
- {/* @ts-ignore */}
75
- <Component {...componentProps} />
76
- </ReactNodeViewContext.Provider>
77
- </>
73
+ <ReactNodeViewContext.Provider value={context}>
74
+ {React.createElement(Component, componentProps)}
75
+ </ReactNodeViewContext.Provider>
78
76
  )
79
- }
77
+ })
80
78
 
81
79
  ReactNodeViewProvider.displayName = 'ReactNodeView'
82
80
 
@@ -1,5 +1,6 @@
1
1
  import { Editor } from '@tiptap/core'
2
2
  import React from 'react'
3
+ import { flushSync } from 'react-dom'
3
4
 
4
5
  import { Editor as ExtendedEditor } from './Editor.js'
5
6
 
@@ -121,7 +122,15 @@ export class ReactRenderer<R = unknown, P = unknown> {
121
122
  })
122
123
  }
123
124
 
124
- this.render()
125
+ if (this.editor.isInitialized) {
126
+ // On first render, we need to flush the render synchronously
127
+ // Renders afterwards can be async, but this fixes a cursor positioning issue
128
+ flushSync(() => {
129
+ this.render()
130
+ })
131
+ } else {
132
+ this.render()
133
+ }
125
134
  }
126
135
 
127
136
  render(): void {
@@ -134,7 +143,7 @@ export class ReactRenderer<R = unknown, P = unknown> {
134
143
  }
135
144
  }
136
145
 
137
- this.reactElement = <Component {...props } />
146
+ this.reactElement = React.createElement(Component, props)
138
147
 
139
148
  this.editor?.contentComponent?.setRenderer(this.id, this)
140
149
  }
package/src/useEditor.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EditorOptions } from '@tiptap/core'
1
+ import { type EditorOptions, Editor } from '@tiptap/core'
2
2
  import {
3
3
  DependencyList,
4
4
  MutableRefObject,
@@ -9,7 +9,6 @@ import {
9
9
  } from 'react'
10
10
  import { useSyncExternalStore } from 'use-sync-external-store/shim'
11
11
 
12
- import { Editor } from './Editor.js'
13
12
  import { useEditorState } from './useEditorState.js'
14
13
 
15
14
  const isDev = process.env.NODE_ENV !== 'production'
@@ -137,18 +136,20 @@ class EditorInstanceManager {
137
136
  * Create a new editor instance. And attach event listeners.
138
137
  */
139
138
  private createEditor(): Editor {
140
- const editor = new Editor(this.options.current)
141
-
142
- // Always call the most recent version of the callback function by default
143
- editor.on('beforeCreate', (...args) => this.options.current.onBeforeCreate?.(...args))
144
- editor.on('blur', (...args) => this.options.current.onBlur?.(...args))
145
- editor.on('create', (...args) => this.options.current.onCreate?.(...args))
146
- editor.on('destroy', (...args) => this.options.current.onDestroy?.(...args))
147
- editor.on('focus', (...args) => this.options.current.onFocus?.(...args))
148
- editor.on('selectionUpdate', (...args) => this.options.current.onSelectionUpdate?.(...args))
149
- editor.on('transaction', (...args) => this.options.current.onTransaction?.(...args))
150
- editor.on('update', (...args) => this.options.current.onUpdate?.(...args))
151
- editor.on('contentError', (...args) => this.options.current.onContentError?.(...args))
139
+ const optionsToApply: Partial<EditorOptions> = {
140
+ ...this.options.current,
141
+ // Always call the most recent version of the callback function by default
142
+ onBeforeCreate: (...args) => this.options.current.onBeforeCreate?.(...args),
143
+ onBlur: (...args) => this.options.current.onBlur?.(...args),
144
+ onCreate: (...args) => this.options.current.onCreate?.(...args),
145
+ onDestroy: (...args) => this.options.current.onDestroy?.(...args),
146
+ onFocus: (...args) => this.options.current.onFocus?.(...args),
147
+ onSelectionUpdate: (...args) => this.options.current.onSelectionUpdate?.(...args),
148
+ onTransaction: (...args) => this.options.current.onTransaction?.(...args),
149
+ onUpdate: (...args) => this.options.current.onUpdate?.(...args),
150
+ onContentError: (...args) => this.options.current.onContentError?.(...args),
151
+ }
152
+ const editor = new Editor(optionsToApply)
152
153
 
153
154
  // no need to keep track of the event listeners, they will be removed when the editor is destroyed
154
155
 
@@ -216,7 +217,6 @@ class EditorInstanceManager {
216
217
  * Recreate the editor instance if the dependencies have changed.
217
218
  */
218
219
  private refreshEditorInstance(deps: DependencyList) {
219
-
220
220
  if (this.editor && !this.editor.isDestroyed) {
221
221
  // Editor instance already exists
222
222
  if (this.previousDeps === null) {
@@ -1,8 +1,7 @@
1
+ import type { Editor } from '@tiptap/core'
1
2
  import { useDebugValue, useEffect, useState } from 'react'
2
3
  import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
3
4
 
4
- import type { Editor } from './Editor.js'
5
-
6
5
  export type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> = {
7
6
  editor: TEditor;
8
7
  transactionNumber: number;