Created
April 29, 2022 09:16
-
-
Save BrianHung/f2b9b5de6b05de222cbacaf13745eb3c to your computer and use it in GitHub Desktop.
ProseMirror and Granular Updates with React useSyncExternalStore
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { createContext, useContext, useState, useRef } from "react" | |
import { EditorView, EditorProps } from "prosemirror-view" | |
import { useSyncExternalStore } from "use-sync-external-store/shim" | |
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector" | |
import { memo, useEffect } from "react" | |
import { useEditorContext } from "./hooks/useEditor" | |
export type ReactEditorProps = Partial<EditorProps> & { | |
className?: string; | |
} | |
export const Editor: React.FC<ReactEditorProps> = memo((props) => { | |
const { | |
className = "" | |
} = props | |
const {setEditor, ReactSyncExternalStorePlugin} = useEditorContext() | |
const editorRef = useRef<HTMLDivElement>(null) | |
useEffect( | |
function initEditor() { | |
setEditor(new EditorView({ | |
...props, | |
place: {mount: editorRef.current}, | |
plugins: props.plugins?.concat(ReactSyncExternalStorePlugin), | |
})) | |
}, | |
[], | |
) | |
return ( | |
<div | |
className={className} | |
ref={editorRef} | |
/> | |
) | |
}) | |
export default Editor |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { createContext, useContext, useMemo, useState } from "react" | |
import { EditorView } from "prosemirror-view" | |
import { useSyncExternalStore } from "use-sync-external-store/shim" | |
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector" | |
import { EditorState, Plugin, PluginKey } from "prosemirror-state" | |
import { EditorView } from "prosemirror-view" | |
const ReactSyncExternalStoreKey = new PluginKey("ReactSyncExternalStore") | |
export const EditorContext = createContext<{ | |
editor: EditorView, | |
setEditor: ((editor: EditorView) => void) | |
listeners: Set<EditorListener> | |
subscribe: ((listener: EditorListener) => Function) | |
ReactSyncExternalStorePlugin: Plugin | |
}>(undefined) | |
EditorContext.displayName = 'EditorContext' | |
export const useEditorContext = () => { | |
const context = useContext(EditorContext); | |
if (!context) { | |
throw Error( | |
"EditorContext is not defined. Did you forget to wrap your component in an EditorProvider?" | |
) | |
} | |
return context; | |
} | |
const defaultViewSelector = (view: EditorView) => view | |
/** | |
* Alternatively called `useEditorView`. | |
*/ | |
export const useEditor = ( | |
selector: (view: EditorView) => any = defaultViewSelector, | |
isEqual?: (view: EditorView) => boolean, | |
) => { | |
const context = useEditorContext() | |
return useSyncExternalStoreWithSelector( | |
context.subscribe, | |
() => context.editor, | |
null, | |
selector, | |
isEqual, | |
) | |
} | |
const defaultStateSelector = (state: EditorState) => state | |
export type EditorListener = (view: EditorView, prevState?: EditorState) => void | |
export const useEditorState = ( | |
selector: (state: EditorState) => any = defaultStateSelector, | |
isEqual?: (state: EditorState) => boolean, | |
) => { | |
const context = useEditorContext() | |
return useSyncExternalStoreWithSelector( | |
context.subscribe, | |
() => context.editor?.state, | |
null, | |
selector, | |
isEqual, | |
) | |
} | |
export const EditorProvider = ({children}: {children: React.ReactNode}) => { | |
const [editor, setEditor] = useState<Editor|undefined>(undefined) | |
const [listeners, subscribe, ReactSyncExternalStorePlugin] = useMemo( | |
() => { | |
const listeners = new Set<EditorListener>(); | |
const subscribe = (listener: EditorListener) => { | |
listeners.add(listener); | |
return () => listeners.delete(listener); | |
}; | |
/** | |
* Direct plugin that updates all editor selectors. | |
* https://prosemirror.net/docs/ref/#view.DirectEditorProps.plugins | |
*/ | |
const ReactSyncExternalStorePlugin = new Plugin({ | |
key: ReactSyncExternalStoreKey, | |
view: view => ({ | |
update(view, prevState) { | |
listeners.forEach(l => l(view, prevState)); | |
}, | |
destroy() { | |
listeners.forEach(l => l(view, undefined)); | |
} | |
}) | |
}); | |
return [ | |
listeners, | |
subscribe, | |
ReactSyncExternalStorePlugin | |
] | |
}, | |
[] | |
) | |
return ( | |
<EditorContext.Provider value={{ | |
editor, | |
setEditor, | |
listeners, | |
subscribe, | |
ReactSyncExternalStorePlugin | |
}}> | |
{children} | |
</EditorContext.Provider> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment