@tiptap/react 2.5.0-pre.13 → 2.5.0-pre.14

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,67 @@ 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 [editor, setEditor] = useState(() => {
61
+ if (options.immediatelyRender === undefined) {
62
+ if (isSSR || isNext) {
63
+ // TODO in the next major release, we should throw an error here
64
+ if (isDev) {
65
+ /**
66
+ * Throw an error in development, to make sure the developer is aware that tiptap cannot be SSR'd
67
+ * and that they need to set `immediatelyRender` to `false` to avoid hydration mismatches.
68
+ */
69
+ console.warn(
70
+ 'Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.',
71
+ )
72
+ }
73
+
74
+ // Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production
75
+ return null
76
+ }
77
+
78
+ // Default to immediately rendering when client-side rendering
79
+ return new Editor(options)
80
+ }
81
+
82
+ if (options.immediatelyRender && isSSR && isDev) {
83
+ // Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches.
84
+ throw new Error(
85
+ '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.',
86
+ )
87
+ }
88
+
89
+ if (options.immediatelyRender) {
90
+ return new Editor(options)
91
+ }
92
+
93
+ return null
94
+ })
95
+
96
+ useDebugValue(editor)
97
+
98
+ // This effect will handle creating/updating the editor instance
99
+ useEffect(() => {
100
+ let editorInstance: Editor | null = editor
101
+
102
+ if (!editorInstance) {
103
+ editorInstance = new Editor(options)
104
+ // instantiate the editor if it doesn't exist
105
+ // for ssr, this is the first time the editor is created
106
+ setEditor(editorInstance)
107
+ } else {
108
+ // if the editor does exist, update the editor options accordingly
109
+ editorInstance.setOptions(options)
110
+ }
111
+ }, deps)
21
112
 
22
113
  const {
23
114
  onBeforeCreate,
@@ -44,96 +135,118 @@ export const useEditor = (options: Partial<EditorOptions> = {}, deps: Dependency
44
135
  // This effect will handle updating the editor instance
45
136
  // when the event handlers change.
46
137
  useEffect(() => {
47
- if (!editorRef.current) {
138
+ if (!editor) {
48
139
  return
49
140
  }
50
141
 
51
142
  if (onBeforeCreate) {
52
- editorRef.current.off('beforeCreate', onBeforeCreateRef.current)
53
- editorRef.current.on('beforeCreate', onBeforeCreate)
143
+ editor.off('beforeCreate', onBeforeCreateRef.current)
144
+ editor.on('beforeCreate', onBeforeCreate)
54
145
 
55
146
  onBeforeCreateRef.current = onBeforeCreate
56
147
  }
57
148
 
58
149
  if (onBlur) {
59
- editorRef.current.off('blur', onBlurRef.current)
60
- editorRef.current.on('blur', onBlur)
150
+ editor.off('blur', onBlurRef.current)
151
+ editor.on('blur', onBlur)
61
152
 
62
153
  onBlurRef.current = onBlur
63
154
  }
64
155
 
65
156
  if (onCreate) {
66
- editorRef.current.off('create', onCreateRef.current)
67
- editorRef.current.on('create', onCreate)
157
+ editor.off('create', onCreateRef.current)
158
+ editor.on('create', onCreate)
68
159
 
69
160
  onCreateRef.current = onCreate
70
161
  }
71
162
 
72
163
  if (onDestroy) {
73
- editorRef.current.off('destroy', onDestroyRef.current)
74
- editorRef.current.on('destroy', onDestroy)
164
+ editor.off('destroy', onDestroyRef.current)
165
+ editor.on('destroy', onDestroy)
75
166
 
76
167
  onDestroyRef.current = onDestroy
77
168
  }
78
169
 
79
170
  if (onFocus) {
80
- editorRef.current.off('focus', onFocusRef.current)
81
- editorRef.current.on('focus', onFocus)
171
+ editor.off('focus', onFocusRef.current)
172
+ editor.on('focus', onFocus)
82
173
 
83
174
  onFocusRef.current = onFocus
84
175
  }
85
176
 
86
177
  if (onSelectionUpdate) {
87
- editorRef.current.off('selectionUpdate', onSelectionUpdateRef.current)
88
- editorRef.current.on('selectionUpdate', onSelectionUpdate)
178
+ editor.off('selectionUpdate', onSelectionUpdateRef.current)
179
+ editor.on('selectionUpdate', onSelectionUpdate)
89
180
 
90
181
  onSelectionUpdateRef.current = onSelectionUpdate
91
182
  }
92
183
 
93
184
  if (onTransaction) {
94
- editorRef.current.off('transaction', onTransactionRef.current)
95
- editorRef.current.on('transaction', onTransaction)
185
+ editor.off('transaction', onTransactionRef.current)
186
+ editor.on('transaction', onTransaction)
96
187
 
97
188
  onTransactionRef.current = onTransaction
98
189
  }
99
190
 
100
191
  if (onUpdate) {
101
- editorRef.current.off('update', onUpdateRef.current)
102
- editorRef.current.on('update', onUpdate)
192
+ editor.off('update', onUpdateRef.current)
193
+ editor.on('update', onUpdate)
103
194
 
104
195
  onUpdateRef.current = onUpdate
105
196
  }
106
197
 
107
198
  if (onContentError) {
108
- editorRef.current.off('contentError', onContentErrorRef.current)
109
- editorRef.current.on('contentError', onContentError)
199
+ editor.off('contentError', onContentErrorRef.current)
200
+ editor.on('contentError', onContentError)
110
201
 
111
202
  onContentErrorRef.current = onContentError
112
203
  }
113
- }, [onBeforeCreate, onBlur, onCreate, onDestroy, onFocus, onSelectionUpdate, onTransaction, onUpdate, editorRef.current])
114
-
204
+ }, [
205
+ onBeforeCreate,
206
+ onBlur,
207
+ onCreate,
208
+ onDestroy,
209
+ onFocus,
210
+ onSelectionUpdate,
211
+ onTransaction,
212
+ onUpdate,
213
+ onContentError,
214
+ editor,
215
+ ])
216
+
217
+ /**
218
+ * Destroy the editor instance when the component completely unmounts
219
+ * As opposed to the cleanup function in the effect above, this will
220
+ * only be called when the component is removed from the DOM, since it has no deps.
221
+ * */
115
222
  useEffect(() => {
116
- let isMounted = true
117
-
118
- const editor = new Editor(options)
119
-
120
- editorRef.current = editor
121
-
122
- editorRef.current.on('transaction', () => {
123
- requestAnimationFrame(() => {
124
- requestAnimationFrame(() => {
125
- if (isMounted) {
126
- forceUpdate({})
127
- }
128
- })
129
- })
130
- })
131
-
132
223
  return () => {
133
- isMounted = false
134
- editor.destroy()
135
- }
136
- }, deps)
224
+ if (editor) {
225
+ // We need to destroy the editor asynchronously to avoid memory leaks
226
+ // because the editor instance is still being used in the component.
137
227
 
138
- return editorRef.current
228
+ setTimeout(() => (editor.isDestroyed ? null : editor.destroy()))
229
+ }
230
+ }
231
+ }, [])
232
+
233
+ // The default behavior is to re-render on each transaction
234
+ // This is legacy behavior that will be removed in future versions
235
+ useEditorState({
236
+ editor,
237
+ selector: ({ transactionNumber }) => {
238
+ if (options.shouldRerenderOnTransaction === false) {
239
+ // This will prevent the editor from re-rendering on each transaction
240
+ return null
241
+ }
242
+
243
+ // This will avoid re-rendering on the first transaction when `immediatelyRender` is set to `true`
244
+ if (options.immediatelyRender && transactionNumber === 0) {
245
+ return 0
246
+ }
247
+ return transactionNumber + 1
248
+ },
249
+ })
250
+
251
+ return editor
139
252
  }
@@ -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 | null>
99
+ ): TSelectorResult | null;
100
+ export function useEditorState<TSelectorResult>(
101
+ options: UseEditorStateOptions<TSelectorResult, Editor>
102
+ ): TSelectorResult;
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
+ }