diff --git a/.changeset/brave-hats-carry.md b/.changeset/brave-hats-carry.md new file mode 100644 index 0000000000..8ca4d0da24 --- /dev/null +++ b/.changeset/brave-hats-carry.md @@ -0,0 +1,5 @@ +--- +'@platejs/core': minor +--- + +Add useNodePath plugin which will re-render when path changes diff --git a/packages/core/src/lib/plugins/UseNodePathPlugin.ts b/packages/core/src/lib/plugins/UseNodePathPlugin.ts new file mode 100644 index 0000000000..68f8b7395d --- /dev/null +++ b/packages/core/src/lib/plugins/UseNodePathPlugin.ts @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react'; + +import { + type Path, + type PathRef, + type PluginConfig, + type TNode, + type Value, + OperationApi, + PathApi, +} from 'platejs'; +import { + type TPlateEditor, + createTPlatePlugin, + useEditorRef, +} from 'platejs/react'; + +const KEY = 'useNodePath'; + +type Listener = { + id: string; + pathRef: PathRef; + prevPath: PathRef['current']; + fn: (path: PathRef['current']) => void; +}; + +type UseNodePathConfig = PluginConfig< + typeof KEY, + { listeners: Listener[] }, + Record< + typeof KEY, + { + addListener: (path: Path, fn: Listener['fn']) => () => void; + } + > +>; + +export const UseNodePathPlugin = createTPlatePlugin({ + key: KEY, + options: { + listeners: [], + }, +}) + .extendApi( + ({ editor, getOption, setOption }) => ({ + addListener: (path, fn) => { + const id = crypto.randomUUID(); + const pathRef = editor.api.pathRef(path, { affinity: 'backward' }); + + getOption('listeners').push({ + id, + fn, + pathRef, + prevPath: pathRef.current ? [...pathRef.current] : pathRef.current, + }); + + return () => { + setOption( + 'listeners', + getOption('listeners').filter((item) => item.id !== id) + ); + }; + }, + }) + ) + .overrideEditor(({ getOption, tf: { apply } }) => ({ + transforms: { + apply: (op) => { + apply(op); + + if (OperationApi.isNodeOperation(op)) { + getOption('listeners').forEach((item) => { + if ( + ((item.pathRef.current === null || item.prevPath === null) && + item.pathRef.current !== item.prevPath) || + (item.pathRef.current && + item.prevPath && + !PathApi.equals(item.pathRef.current, item.prevPath)) + ) { + item.fn(item.pathRef.current); + + // eslint-disable-next-line no-param-reassign + item.prevPath = + item.pathRef.current === null + ? null + : [...item.pathRef.current]; + } + }); + } + }, + }, + })); + +export const useNodePath = (node: TNode) => { + const editor = useEditorRef>(); + const [path, setPath] = useState( + () => editor.api.findPath(node) ?? null + ); + + useEffect(() => { + if (!(KEY in editor.plugins) || !path) { + return; + } + + return editor.api[KEY].addListener(path, (path) => { + setPath(path); + }); + }, []); + + return path; +};