Skip to content

Commit

Permalink
feat(react): add useEditorState hook for subscribing to selected ed…
Browse files Browse the repository at this point in the history
…itor state
  • Loading branch information
nperez0111 committed Jul 7, 2024
1 parent a261a82 commit f157449
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 192 deletions.
4 changes: 4 additions & 0 deletions demos/src/Examples/CustomParagraph/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Paragraph } from './Paragraph.jsx'

export default () => {
const editor = useEditor({
immediatelyRender: true,
shouldRerenderOnTransaction: false,
extensions: [
StarterKit.configure({
paragraph: false,
Expand All @@ -21,6 +23,8 @@ export default () => {
`,
})

console.count('render')

return (
<EditorContent editor={editor} />
)
Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/Context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,20 @@ export const EditorConsumer = EditorContext.Consumer
*/
export const useCurrentEditor = () => useContext(EditorContext)

export type EditorProviderProps<TSelectorResult> = {
export type EditorProviderProps = {
children?: ReactNode;
slotBefore?: ReactNode;
slotAfter?: ReactNode;
} & UseEditorOptions<TSelectorResult>
} & UseEditorOptions

/**
* This is the provider component for the editor.
* It allows the editor to be accessible across the entire component tree
* with `useCurrentEditor`.
*/
export function EditorProvider<TSelectorResult>({
export function EditorProvider({
children, slotAfter, slotBefore, ...editorOptions
}: EditorProviderProps<TSelectorResult>) {
}: EditorProviderProps) {
const editor = useEditor(editorOptions)

if (!editor) {
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export * from './NodeViewWrapper.js'
export * from './ReactNodeViewRenderer.js'
export * from './ReactRenderer.js'
export * from './useEditor.js'
export * from './useEditorState.js'
export * from './useReactNodeView.js'
export * from '@tiptap/core'
237 changes: 49 additions & 188 deletions packages/react/src/useEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { EditorOptions } from '@tiptap/core'
import {
DependencyList, useDebugValue, useEffect, useRef, useState,
} from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim'

import { Editor } from './Editor.js'
import { useEditorState } from './useEditorState.js'

const isDev = process.env.NODE_ENV !== 'production'
const isSSR = typeof window === 'undefined'
Expand All @@ -13,7 +13,7 @@ const isNext = isSSR || Boolean(typeof window !== 'undefined' && (window as any)
/**
* The options for the `useEditor` hook.
*/
export type UseEditorOptions<TSelectorResult> = Partial<EditorOptions> & {
export type UseEditorOptions = Partial<EditorOptions> & {
/**
* Whether to render the editor on the first render.
* If client-side rendering, set this to `true`.
Expand All @@ -22,139 +22,24 @@ export type UseEditorOptions<TSelectorResult> = Partial<EditorOptions> & {
*/
immediatelyRender?: boolean;
/**
* A selector function to determine the value to compare for re-rendering.
* @default `({ transactionNumber }) => transactionNumber + 1`
*/
selector?: (
editor: Editor,
options: {
/**
* The previous value returned by the selector.
*/
previousValue: TSelectorResult | null;
/**
* The current transaction number. Incremented on every transaction.
*/
transactionNumber: number;
}
) => TSelectorResult;
/**
* A custom equality function to determine if the editor should re-render.
* @default `(a, b) => a === b`
* Whether to re-render the editor on each transaction.
* This is legacy behavior that will be removed in future versions.
* @default true
*/
equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean;
shouldRerenderOnTransaction?: boolean;
};

/**
* To synchronize the editor instance with the component state,
* we need to create a separate instance that is not affected by the component re-renders.
*/
function makeEditorInstance<TSelectorResult>({
immediatelyRender,
options: initialOptions,
selector = (_e, { transactionNumber }) => (transactionNumber + 1) as unknown as TSelectorResult,
equalityFn = (a: TSelectorResult, b: TSelectorResult | null) => a === b,
}: Pick<UseEditorOptions<TSelectorResult>, 'selector' | 'equalityFn' | 'immediatelyRender'> & {
options: Partial<EditorOptions>;
}) {
let editor: Editor | null = null
let transactionNumber = 0
let prevSnapshot: [Editor | null, TSelectorResult | null] = [editor, null]
const subscribers = new Set<() => void>()

const editorInstance = {
/**
* Get the current editor instance.
*/
getSnapshot() {
if (!editor) {
return prevSnapshot
}

const nextSnapshotResult = selector(editor, {
previousValue: prevSnapshot[1],
transactionNumber,
})

if (equalityFn(nextSnapshotResult, prevSnapshot[1])) {
return prevSnapshot
}

const nextSnapshot: [Editor, TSelectorResult | null] = [editor, nextSnapshotResult]

prevSnapshot = nextSnapshot
return nextSnapshot
},
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): [null, null] {
return [null, null]
},
/**
* Subscribe to the editor instance's changes.
*/
subscribe(callback: () => void) {
subscribers.add(callback)
return () => {
subscribers.delete(callback)
}
},
/**
* Create the editor instance.
*/
create(options: Partial<EditorOptions>) {
if (editor) {
editor.destroy()
}
editor = new Editor(options)
subscribers.forEach(callback => callback())

if (editor) {
/**
* This will force a re-render when the editor state changes.
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
* This could be more efficient, but it's a good trade-off for now.
*/
editor.on('transaction', () => {
transactionNumber += 1
subscribers.forEach(callback => callback())
})
}
},
/**
* Destroy the editor instance.
*/
destroy(): void {
if (editor) {
// We need to destroy the editor asynchronously to avoid memory leaks
// because the editor instance is still being used in the component.
const editorToDestroy = editor

setTimeout(() => editorToDestroy.destroy())
}
editor = null
},
}

if (immediatelyRender) {
editorInstance.create(initialOptions)
}

return editorInstance
}

/**
* This hook allows you to create an editor instance.
* @param options The editor options
* @param deps The dependencies to watch for changes
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
function useEditorWithState<TSelectorResult>(
options: UseEditorOptions<TSelectorResult> & { immediatelyRender: true },
export function useEditor(
options: UseEditorOptions & { immediatelyRender: true },
deps?: DependencyList
): { editor: Editor; state: TSelectorResult | null };
): Editor;

/**
* This hook allows you to create an editor instance.
Expand All @@ -163,23 +48,16 @@ function useEditorWithState<TSelectorResult>(
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
function useEditorWithState<TSelectorResult>(
options?: UseEditorOptions<TSelectorResult>,
export function useEditor(
options?: UseEditorOptions,
deps?: DependencyList
): { editor: Editor | null; state: TSelectorResult | null };
): Editor | null;

function useEditorWithState<TSelectorResult>(
options: UseEditorOptions<TSelectorResult> = {},
export function useEditor(
options: UseEditorOptions = {},
deps: DependencyList = [],
): { editor: Editor | null; state: TSelectorResult | null } {
const [editorInstance] = useState(() => {
const instanceCreateOptions: Parameters<typeof makeEditorInstance<TSelectorResult>>[0] = {
immediatelyRender: Boolean(options.immediatelyRender),
equalityFn: options.equalityFn,
selector: options.selector,
options,
}

): Editor | null {
const [editor, setEditor] = useState(() => {
if (options.immediatelyRender === undefined) {
if (isSSR || isNext) {
// TODO in the next major release, we should throw an error here
Expand All @@ -194,13 +72,11 @@ function useEditorWithState<TSelectorResult>(
}

// Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production
instanceCreateOptions.immediatelyRender = false
return makeEditorInstance(instanceCreateOptions)
return null
}

// Default to `true` in client-side rendering
instanceCreateOptions.immediatelyRender = true
return makeEditorInstance(instanceCreateOptions)
// Default to immediately rendering when client-side rendering
return new Editor(options)
}

if (options.immediatelyRender && isSSR && isDev) {
Expand All @@ -210,27 +86,27 @@ function useEditorWithState<TSelectorResult>(
)
}

return makeEditorInstance(instanceCreateOptions)
})
if (options.immediatelyRender) {
return new Editor(options)
}

// Using the `useSyncExternalStore` hook to sync the editor instance with the component state
const [editor, selectedState] = useSyncExternalStore(
editorInstance.subscribe,
editorInstance.getSnapshot,
editorInstance.getServerSnapshot,
)
return null
})

useDebugValue(editor)

// This effect will handle creating/updating the editor instance
useEffect(() => {
if (!editor) {
let editorInstance: Editor | null = editor

if (!editorInstance) {
editorInstance = new Editor(options)
// instantiate the editor if it doesn't exist
// for ssr, this is the first time the editor is created
editorInstance.create(options)
setEditor(editorInstance)
} else {
// if the editor does exist, update the editor options accordingly
editor.setOptions(options)
editorInstance.setOptions(options)
}
}, deps)

Expand Down Expand Up @@ -345,42 +221,27 @@ function useEditorWithState<TSelectorResult>(
* */
useEffect(() => {
return () => {
editorInstance.destroy()
if (editor) {
// We need to destroy the editor asynchronously to avoid memory leaks
// because the editor instance is still being used in the component.

setTimeout(() => (editor.isDestroyed ? null : editor.destroy()))
}
}
}, [])

return { editor, state: selectedState }
}

/**
* This hook allows you to create an editor instance.
* @param options The editor options
* @param deps The dependencies to watch for changes
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
function useEditor<TSelectorResult>(
options: UseEditorOptions<TSelectorResult> & { immediatelyRender: true },
deps?: DependencyList
): Editor;

/**
* This hook allows you to create an editor instance.
* @param options The editor options
* @param deps The dependencies to watch for changes
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
function useEditor<TSelectorResult>(
options?: UseEditorOptions<TSelectorResult>,
deps?: DependencyList
): Editor | null;
// The default behavior is to re-render on each transaction
// This is legacy behavior that will be removed in future versions
useEditorState({
editor,
selector: ({ transactionNumber }) => {
if (options.shouldRerenderOnTransaction === false) {
// This will prevent the editor from re-rendering on each transaction
return null
}
return transactionNumber + 1
},
})

function useEditor<TSelectorResult>(
options: UseEditorOptions<TSelectorResult> = {},
deps: DependencyList = [],
): Editor | null {
return useEditorWithState(options, deps).editor
return editor
}

export { useEditor, useEditorWithState }
Loading

0 comments on commit f157449

Please sign in to comment.