@tiptap/react 2.5.0-pre.8 → 3.0.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.
package/src/useEditor.ts CHANGED
@@ -1,12 +1,45 @@
1
1
  import { EditorOptions } from '@tiptap/core'
2
2
  import {
3
- DependencyList,
4
- useEffect,
5
- useRef,
6
- useState,
3
+ DependencyList, useDebugValue, useEffect, useRef, useState,
7
4
  } from 'react'
8
5
 
9
6
  import { Editor } from './Editor.js'
7
+ import { useEditorState } from './useEditorState.js'
8
+
9
+ const isDev = process.env.NODE_ENV !== 'production'
10
+ const isSSR = typeof window === 'undefined'
11
+ const isNext = isSSR || Boolean(typeof window !== 'undefined' && (window as any).next)
12
+
13
+ /**
14
+ * The options for the `useEditor` hook.
15
+ */
16
+ export type UseEditorOptions = Partial<EditorOptions> & {
17
+ /**
18
+ * Whether to render the editor on the first render.
19
+ * If client-side rendering, set this to `true`.
20
+ * If server-side rendering, set this to `false`.
21
+ * @default true
22
+ */
23
+ immediatelyRender?: boolean;
24
+ /**
25
+ * Whether to re-render the editor on each transaction.
26
+ * This is legacy behavior that will be removed in future versions.
27
+ * @default true
28
+ */
29
+ shouldRerenderOnTransaction?: boolean;
30
+ };
31
+
32
+ /**
33
+ * This hook allows you to create an editor instance.
34
+ * @param options The editor options
35
+ * @param deps The dependencies to watch for changes
36
+ * @returns The editor instance
37
+ * @example const editor = useEditor({ extensions: [...] })
38
+ */
39
+ export function useEditor(
40
+ options: UseEditorOptions & { immediatelyRender: true },
41
+ deps?: DependencyList
42
+ ): Editor;
10
43
 
11
44
  /**
12
45
  * This hook allows you to create an editor instance.
@@ -15,9 +48,68 @@ import { Editor } from './Editor.js'
15
48
  * @returns The editor instance
16
49
  * @example const editor = useEditor({ extensions: [...] })
17
50
  */
18
- export const useEditor = (options: Partial<EditorOptions> = {}, deps: DependencyList = []) => {
19
- const editorRef = useRef<Editor | null>(null)
20
- const [, forceUpdate] = useState({})
51
+ export function useEditor(
52
+ options?: UseEditorOptions,
53
+ deps?: DependencyList
54
+ ): Editor | null;
55
+
56
+ export function useEditor(
57
+ options: UseEditorOptions = {},
58
+ deps: DependencyList = [],
59
+ ): Editor | null {
60
+ const isMounted = useRef(false)
61
+ const [editor, setEditor] = useState(() => {
62
+ if (options.immediatelyRender === undefined) {
63
+ if (isSSR || isNext) {
64
+ // TODO in the next major release, we should throw an error here
65
+ if (isDev) {
66
+ /**
67
+ * Throw an error in development, to make sure the developer is aware that tiptap cannot be SSR'd
68
+ * and that they need to set `immediatelyRender` to `false` to avoid hydration mismatches.
69
+ */
70
+ console.warn(
71
+ 'Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.',
72
+ )
73
+ }
74
+
75
+ // Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production
76
+ return null
77
+ }
78
+
79
+ // Default to immediately rendering when client-side rendering
80
+ return new Editor(options)
81
+ }
82
+
83
+ if (options.immediatelyRender && isSSR && isDev) {
84
+ // Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches.
85
+ throw new Error(
86
+ 'Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.',
87
+ )
88
+ }
89
+
90
+ if (options.immediatelyRender) {
91
+ return new Editor(options)
92
+ }
93
+
94
+ return null
95
+ })
96
+
97
+ useDebugValue(editor)
98
+
99
+ // This effect will handle creating/updating the editor instance
100
+ useEffect(() => {
101
+ let editorInstance: Editor | null = editor
102
+
103
+ if (!editorInstance) {
104
+ editorInstance = new Editor(options)
105
+ // instantiate the editor if it doesn't exist
106
+ // for ssr, this is the first time the editor is created
107
+ setEditor(editorInstance)
108
+ } else {
109
+ // if the editor does exist, update the editor options accordingly
110
+ editorInstance.setOptions(options)
111
+ }
112
+ }, deps)
21
113
 
22
114
  const {
23
115
  onBeforeCreate,
@@ -44,96 +136,127 @@ export const useEditor = (options: Partial<EditorOptions> = {}, deps: Dependency
44
136
  // This effect will handle updating the editor instance
45
137
  // when the event handlers change.
46
138
  useEffect(() => {
47
- if (!editorRef.current) {
139
+ if (!editor) {
48
140
  return
49
141
  }
50
142
 
51
143
  if (onBeforeCreate) {
52
- editorRef.current.off('beforeCreate', onBeforeCreateRef.current)
53
- editorRef.current.on('beforeCreate', onBeforeCreate)
144
+ editor.off('beforeCreate', onBeforeCreateRef.current)
145
+ editor.on('beforeCreate', onBeforeCreate)
54
146
 
55
147
  onBeforeCreateRef.current = onBeforeCreate
56
148
  }
57
149
 
58
150
  if (onBlur) {
59
- editorRef.current.off('blur', onBlurRef.current)
60
- editorRef.current.on('blur', onBlur)
151
+ editor.off('blur', onBlurRef.current)
152
+ editor.on('blur', onBlur)
61
153
 
62
154
  onBlurRef.current = onBlur
63
155
  }
64
156
 
65
157
  if (onCreate) {
66
- editorRef.current.off('create', onCreateRef.current)
67
- editorRef.current.on('create', onCreate)
158
+ editor.off('create', onCreateRef.current)
159
+ editor.on('create', onCreate)
68
160
 
69
161
  onCreateRef.current = onCreate
70
162
  }
71
163
 
72
164
  if (onDestroy) {
73
- editorRef.current.off('destroy', onDestroyRef.current)
74
- editorRef.current.on('destroy', onDestroy)
165
+ editor.off('destroy', onDestroyRef.current)
166
+ editor.on('destroy', onDestroy)
75
167
 
76
168
  onDestroyRef.current = onDestroy
77
169
  }
78
170
 
79
171
  if (onFocus) {
80
- editorRef.current.off('focus', onFocusRef.current)
81
- editorRef.current.on('focus', onFocus)
172
+ editor.off('focus', onFocusRef.current)
173
+ editor.on('focus', onFocus)
82
174
 
83
175
  onFocusRef.current = onFocus
84
176
  }
85
177
 
86
178
  if (onSelectionUpdate) {
87
- editorRef.current.off('selectionUpdate', onSelectionUpdateRef.current)
88
- editorRef.current.on('selectionUpdate', onSelectionUpdate)
179
+ editor.off('selectionUpdate', onSelectionUpdateRef.current)
180
+ editor.on('selectionUpdate', onSelectionUpdate)
89
181
 
90
182
  onSelectionUpdateRef.current = onSelectionUpdate
91
183
  }
92
184
 
93
185
  if (onTransaction) {
94
- editorRef.current.off('transaction', onTransactionRef.current)
95
- editorRef.current.on('transaction', onTransaction)
186
+ editor.off('transaction', onTransactionRef.current)
187
+ editor.on('transaction', onTransaction)
96
188
 
97
189
  onTransactionRef.current = onTransaction
98
190
  }
99
191
 
100
192
  if (onUpdate) {
101
- editorRef.current.off('update', onUpdateRef.current)
102
- editorRef.current.on('update', onUpdate)
193
+ editor.off('update', onUpdateRef.current)
194
+ editor.on('update', onUpdate)
103
195
 
104
196
  onUpdateRef.current = onUpdate
105
197
  }
106
198
 
107
199
  if (onContentError) {
108
- editorRef.current.off('contentError', onContentErrorRef.current)
109
- editorRef.current.on('contentError', onContentError)
200
+ editor.off('contentError', onContentErrorRef.current)
201
+ editor.on('contentError', onContentError)
110
202
 
111
203
  onContentErrorRef.current = onContentError
112
204
  }
113
- }, [onBeforeCreate, onBlur, onCreate, onDestroy, onFocus, onSelectionUpdate, onTransaction, onUpdate, editorRef.current])
205
+ }, [
206
+ onBeforeCreate,
207
+ onBlur,
208
+ onCreate,
209
+ onDestroy,
210
+ onFocus,
211
+ onSelectionUpdate,
212
+ onTransaction,
213
+ onUpdate,
214
+ onContentError,
215
+ editor,
216
+ ])
114
217
 
218
+ /**
219
+ * Destroy the editor instance when the component completely unmounts
220
+ * As opposed to the cleanup function in the effect above, this will
221
+ * only be called when the component is removed from the DOM, since it has no deps.
222
+ * */
115
223
  useEffect(() => {
116
- let isMounted = true
117
-
118
- const editor = new Editor(options)
119
-
120
- editorRef.current = editor
224
+ isMounted.current = true
225
+ return () => {
226
+ isMounted.current = false
227
+ if (editor) {
228
+ // We need to destroy the editor asynchronously to avoid memory leaks
229
+ // because the editor instance is still being used in the component.
121
230
 
122
- editorRef.current.on('transaction', () => {
123
- requestAnimationFrame(() => {
124
- requestAnimationFrame(() => {
125
- if (isMounted) {
126
- forceUpdate({})
231
+ setTimeout(() => {
232
+ // re-use the editor instance if it hasn't been destroyed yet
233
+ // and the component is still mounted
234
+ // otherwise, asynchronously destroy the editor instance
235
+ if (!isMounted.current && !editor.isDestroyed) {
236
+ editor.destroy()
127
237
  }
128
238
  })
129
- })
130
- })
131
-
132
- return () => {
133
- isMounted = false
134
- editor.destroy()
239
+ }
135
240
  }
136
- }, deps)
241
+ }, [])
242
+
243
+ // The default behavior is to re-render on each transaction
244
+ // This is legacy behavior that will be removed in future versions
245
+ useEditorState({
246
+ editor,
247
+ selector: ({ transactionNumber }) => {
248
+ if (options.shouldRerenderOnTransaction === false) {
249
+ // This will prevent the editor from re-rendering on each transaction
250
+ return null
251
+ }
252
+
253
+ // This will avoid re-rendering on the first transaction when `immediatelyRender` is set to `true`
254
+ if (options.immediatelyRender && transactionNumber === 0) {
255
+ return 0
256
+ }
257
+ return transactionNumber + 1
258
+ },
259
+ })
137
260
 
138
- return editorRef.current
261
+ return editor
139
262
  }
@@ -0,0 +1,125 @@
1
+ import { useDebugValue, useEffect, useState } from 'react'
2
+ import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
3
+
4
+ import type { Editor } from './Editor.js'
5
+
6
+ export type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> = {
7
+ editor: TEditor;
8
+ transactionNumber: number;
9
+ };
10
+ export type UseEditorStateOptions<
11
+ TSelectorResult,
12
+ TEditor extends Editor | null = Editor | null,
13
+ > = {
14
+ /**
15
+ * The editor instance.
16
+ */
17
+ editor: TEditor;
18
+ /**
19
+ * A selector function to determine the value to compare for re-rendering.
20
+ */
21
+ selector: (context: EditorStateSnapshot<TEditor>) => TSelectorResult;
22
+ /**
23
+ * A custom equality function to determine if the editor should re-render.
24
+ * @default `(a, b) => a === b`
25
+ */
26
+ equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean;
27
+ };
28
+
29
+ /**
30
+ * To synchronize the editor instance with the component state,
31
+ * we need to create a separate instance that is not affected by the component re-renders.
32
+ */
33
+ function makeEditorStateInstance<TEditor extends Editor | null = Editor | null>(initialEditor: TEditor) {
34
+ let transactionNumber = 0
35
+ let lastTransactionNumber = 0
36
+ let lastSnapshot: EditorStateSnapshot<TEditor> = { editor: initialEditor, transactionNumber: 0 }
37
+ let editor = initialEditor
38
+ const subscribers = new Set<() => void>()
39
+
40
+ const editorInstance = {
41
+ /**
42
+ * Get the current editor instance.
43
+ */
44
+ getSnapshot(): EditorStateSnapshot<TEditor> {
45
+ if (transactionNumber === lastTransactionNumber) {
46
+ return lastSnapshot
47
+ }
48
+ lastTransactionNumber = transactionNumber
49
+ lastSnapshot = { editor, transactionNumber }
50
+ return lastSnapshot
51
+ },
52
+ /**
53
+ * Always disable the editor on the server-side.
54
+ */
55
+ getServerSnapshot(): EditorStateSnapshot<null> {
56
+ return { editor: null, transactionNumber: 0 }
57
+ },
58
+ /**
59
+ * Subscribe to the editor instance's changes.
60
+ */
61
+ subscribe(callback: () => void) {
62
+ subscribers.add(callback)
63
+ return () => {
64
+ subscribers.delete(callback)
65
+ }
66
+ },
67
+ /**
68
+ * Watch the editor instance for changes.
69
+ */
70
+ watch(nextEditor: Editor | null) {
71
+ editor = nextEditor as TEditor
72
+
73
+ if (editor) {
74
+ /**
75
+ * This will force a re-render when the editor state changes.
76
+ * This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
77
+ * This could be more efficient, but it's a good trade-off for now.
78
+ */
79
+ const fn = () => {
80
+ transactionNumber += 1
81
+ subscribers.forEach(callback => callback())
82
+ }
83
+
84
+ const currentEditor = editor
85
+
86
+ currentEditor.on('transaction', fn)
87
+ return () => {
88
+ currentEditor.off('transaction', fn)
89
+ }
90
+ }
91
+ },
92
+ }
93
+
94
+ return editorInstance
95
+ }
96
+
97
+ export function useEditorState<TSelectorResult>(
98
+ options: UseEditorStateOptions<TSelectorResult, Editor>
99
+ ): TSelectorResult;
100
+ export function useEditorState<TSelectorResult>(
101
+ options: UseEditorStateOptions<TSelectorResult, Editor | null>
102
+ ): TSelectorResult | null;
103
+
104
+ export function useEditorState<TSelectorResult>(
105
+ options: UseEditorStateOptions<TSelectorResult, Editor> | UseEditorStateOptions<TSelectorResult, Editor | null>,
106
+ ): TSelectorResult | null {
107
+ const [editorInstance] = useState(() => makeEditorStateInstance(options.editor))
108
+
109
+ // Using the `useSyncExternalStore` hook to sync the editor instance with the component state
110
+ const selectedState = useSyncExternalStoreWithSelector(
111
+ editorInstance.subscribe,
112
+ editorInstance.getSnapshot,
113
+ editorInstance.getServerSnapshot,
114
+ options.selector as UseEditorStateOptions<TSelectorResult, Editor | null>['selector'],
115
+ options.equalityFn,
116
+ )
117
+
118
+ useEffect(() => {
119
+ return editorInstance.watch(options.editor)
120
+ }, [options.editor])
121
+
122
+ useDebugValue(selectedState)
123
+
124
+ return selectedState
125
+ }