import type { Editor } from '@tiptap/core' import deepEqual from 'fast-deep-equal/es6/react' import { useDebugValue, useEffect, useState } from 'react' import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector' export type EditorStateSnapshot = { editor: TEditor; transactionNumber: number; }; export type UseEditorStateOptions< TSelectorResult, TEditor extends Editor | null = Editor | null, > = { /** * The editor instance. */ editor: TEditor; /** * A selector function to determine the value to compare for re-rendering. */ selector: (context: EditorStateSnapshot) => TSelectorResult; /** * A custom equality function to determine if the editor should re-render. * @default `deepEqual` from `fast-deep-equal` */ equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => 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. */ class EditorStateManager { private transactionNumber = 0 private lastTransactionNumber = 0 private lastSnapshot: EditorStateSnapshot private editor: TEditor private subscribers = new Set<() => void>() constructor(initialEditor: TEditor) { this.editor = initialEditor this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 } this.getSnapshot = this.getSnapshot.bind(this) this.getServerSnapshot = this.getServerSnapshot.bind(this) this.watch = this.watch.bind(this) this.subscribe = this.subscribe.bind(this) } /** * Get the current editor instance. */ getSnapshot(): EditorStateSnapshot { if (this.transactionNumber === this.lastTransactionNumber) { return this.lastSnapshot } this.lastTransactionNumber = this.transactionNumber this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber } return this.lastSnapshot } /** * Always disable the editor on the server-side. */ getServerSnapshot(): EditorStateSnapshot { return { editor: null, transactionNumber: 0 } } /** * Subscribe to the editor instance's changes. */ subscribe(callback: () => void): () => void { this.subscribers.add(callback) return () => { this.subscribers.delete(callback) } } /** * Watch the editor instance for changes. */ watch(nextEditor: Editor | null): undefined | (() => void) { this.editor = nextEditor as TEditor if (this.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. */ const fn = () => { this.transactionNumber += 1 this.subscribers.forEach(callback => callback()) } const currentEditor = this.editor currentEditor.on('transaction', fn) return () => { currentEditor.off('transaction', fn) } } return undefined } } export function useEditorState( options: UseEditorStateOptions ): TSelectorResult; export function useEditorState( options: UseEditorStateOptions ): TSelectorResult | null; export function useEditorState( options: UseEditorStateOptions | UseEditorStateOptions, ): TSelectorResult | null { const [editorInstance] = useState(() => new EditorStateManager(options.editor)) // Using the `useSyncExternalStore` hook to sync the editor instance with the component state const selectedState = useSyncExternalStoreWithSelector( editorInstance.subscribe, editorInstance.getSnapshot, editorInstance.getServerSnapshot, options.selector as UseEditorStateOptions['selector'], options.equalityFn ?? deepEqual, ) useEffect(() => { return editorInstance.watch(options.editor) }, [options.editor, editorInstance]) useDebugValue(selectedState) return selectedState }