Skip to content

Commit

Permalink
Merge pull request #2859 from udecode/feat/plate-controller
Browse files Browse the repository at this point in the history
Implement PlateController
  • Loading branch information
zbeyens authored Jan 8, 2024
2 parents 8050148 + f98f69e commit 25ff6af
Show file tree
Hide file tree
Showing 35 changed files with 1,050 additions and 148 deletions.
20 changes: 20 additions & 0 deletions .changeset/late-bags-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@udecode/plate-core": minor
---

- Introduce `PlateController` as a way of accessing the active editor from an ancestor or sibling of `Plate` (see [Accessing the Editor](https://platejs.org/docs/accessing-editor#from-a-sibling-or-ancestor-of-plate)).
- Add `primary` prop to `Plate` (default true)
- Add `isFallback` to `editor` instance (default false)
- The following hooks now throw a runtime error when used outside of either a `Plate` or `PlateController`, and accept a `debugHookName` option to customize this error message:
- `useIncrementVersion`
- `useRedecorate`
- `useReplaceEditor`
- `useEditorMounted` (new)
- `useEditorReadOnly`
- `useEditorRef`
- `useEdtiorSelection`
- `useEditorSelector`
- `useEditorState`
- `useEditorVersion`
- `useSelectionVersion`
- Change the default `id` of a `Plate` editor from `'plate'` to a random value generated with `nanoid/non-secure`
100 changes: 42 additions & 58 deletions apps/www/content/docs/accessing-editor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Use the **`useEditorRef`**, **`useEditorSelector`** or **`useEditorState`** hook

You can call these hooks from any React component that is rendered as a descendant of the **`Plate`** component, including [Plugin Components](/docs/plugin-components).

```tsx
```tsx showLineNumbers {2}
const Toolbar = () => {
const boldActive = useEditorSelector((editor) => isMarkActive(editor, MARK_BOLD), []);
// ...
Expand Down Expand Up @@ -136,88 +136,72 @@ export default () => (
```

<Callout className="my-4">
**If **`editor`** is modified by reference, why include it in dependency
**If `editor` is modified by reference, why include it in dependency
lists?**
<br />
Good question! Even though **`editor`** is usually modified by reference,
there are some situations in which it's replaced with a fresh instance, such
as when the editor is reset.
</Callout>

## From an Ancestor
## From a Sibling or Ancestor of Plate

If you need to access the **`editor`** instance from an ancestor of **`PlateContent`**, wrapping the relevant components in a **`Plate`** is the preferred solution. If this is not an option, you can instead use the **`editorRef`** prop to pass a reference to the **`editor`** instance up the React component tree to where it is needed.
If you need to access the **`editor`** outside of the **`Plate`** component, or if you're working with multiple editors and want to access whichever editor is currently active, you can use a **`PlateController`** component. **`PlateController`** is an optional component that can be rendered as an ancestor of one or more **`Plate`** components. For example, you can render a **`PlateController`** at the root of your app.

The **`editorRef`** prop can be used with **`useRef`**, **`useState`**, or a custom ref callback. Regardless of which you use, you'll need to handle the case where **`editor`** is null. This happens when the editor hasn't had a chance to render yet or has unmounted.
The **`PlateController`** will keep track of which editor is currently active. By default, the first mounted editor inside the **`PlateController`** will be used as the active editor (this can be disabled for individual editors by passing **`primary={false}`** to the **`Plate`** component). After that, every time an editor is focused, that editor becomes active. An editor remains active until another editor is focused or the active editor unmounts.

### With a Ref Object
Inside a **`PlateController`**, hooks like **`useEditorRef`** and **`useEditorSelector`** will return or operate on the currently active editor. If no editor is active, these hooks may return a fallback editor. This can happen under the following circumstances:

```tsx showLineNumbers {2,21}
const App = () => {
const editorRef = useRef<PlateEditor | null>(null);

const handleSomeEvent = useCallback(() => {
// editor has type PlateEditor | null
const editor = editorRef.current;
- When no **`Plate`** component is mounted inside the **`PlateController`**
- When all mounted **`Plate`** components are marked as non-primary
- Temporarily while **`PlateContent`** is in the process of mounting

if (editor) {
// Do something with editor
}
}, []);
The fallback editor is designed to ensure that code querying the **`editor`** produces sensible default values, the same as if they had been given an empty editor with no plugins. The fallback editor cannot be mutated, and will throw a runtime error if any state-changing operations are applied to it. To check if your component is accessing a real editor or a fallback editor, use the **`useEditorMounted`** hook (which returns false for a fallback editor), or check **`editor.isFallback`**.

// Pass editorRef and handleSomeEvent down to where they're needed
```tsx showLineNumbers {2-3,9,19}
const Toolbar = () => {
const editor = useEditorState(); // Returns the active editor (or a fallback editor)
const isMounted = useEditorMounted(); // Returns false if no editor is mounted
// ...
};

const Editor = ({
editorRef,
}: {
editorRef: MutableRefObject<PlateEditor | null>;
}) => (
<Plate editorRef={editorRef}>
<PlateContent />
</Plate>
);
const App = () => {
return (
<PlateController>
<Toolbar />

<Plate>
<PlateContent />
</Plate>

<Plate>
<PlateContent />
</Plate>
</PlateController>
);
};
```

### With State
## From the Parent of Plate

If you want your ancestor component to re-render when the editor content changes, you may want to use **`useState`** to store your **`editor`** instance. Since the **`editorRef`** callback is only called once when the editor first mounts, you'll also need to manually trigger a re-render by updating a counter whenever the **`onChange`** handler of **`Plate`** is called.
The **`editorRef`** prop is an older, alternative way of accessing the **`editor`** outside of **`Plate`**, and works best in cases where the **`editor`** instance must be accessed in the **`Plate`** component's parent. In all other cases, **`PlateController`** is the preferred solution (see above).

Using **`editorRef`** with **`useState`** without a counter is equivalent to using **`useEditorRef`** instead of **`useEditorState`** (the difference is discussed above). Most of the time, if you don't need the ancestor component to re-render on every change, you should be using **`useRef`** instead.
The ref can be created with **`useRef`**, the setter function of **`useState`**, or a custom ref callback. It is populated with the **`editor`** instance when **`Plate`** mounts, and **`null`** when **`Plate`** unmounts. Since the **`editor`** is unavailable during the first render, and may become unavailable if the **`Plate`** component unmounts, you must handle the case where **`editor`** is **`null`**.

```tsx showLineNumbers {2-3,34-35}
```tsx showLineNumbers {2,13}
const App = () => {
const [editor, setEditor] = useState<PlateEditor | null>(null);
const [, handleUpdateEditor] = useReducer((x) => x + 1, 0);

// Pass editor, setEditor and handleUpdateEditor down to where they're needed
// ...
};
const editorRef = useRef<PlateEditor | null>(null);

const EditorPreview = ({ editor }: { editor: PlateEditor | null }) => {
// html has type string | null
const html = useMemo(
() =>
editor &&
serializeHtml(editor, {
nodes: editor.children,
}),
[editor, editor?.children]
);
const handleSomeEvent = useCallback(() => {
const editor = editorRef.current;

if (!html) return null;
return <div dangerouslySetInnerHTML={{ __html: html }} />;
};
// Handle the case where editor is null
if (editor) {
// Do something with editor
}
}, []);

const Editor = ({
setEditor,
handleUpdateEditor,
}: {
setEditor: (editor: PlateEditor | null) => void;
handleUpdateEditor: () => void;
}) => (
<Plate editorRef={setEditor} onChange={handleUpdateEditor}>
<Plate editorRef={editorRef}>
<PlateContent />
</Plate>
);
Expand Down
21 changes: 18 additions & 3 deletions apps/www/content/docs/api/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ The value of the element as a TElement.
Get the Slate editor reference without re-rendering.

- Does not re-render on editor change.
- Should be used inside `Plate`.
- Must be used inside `Plate` or `PlateController` (see [Accessing the Editor](/docs/accessing-editor/)).
- Note the reference does not change when the editor changes.

<APIParameters>
Expand All @@ -336,7 +336,7 @@ Subscribe to a specific property of the editor.

- Calls the selector function on editor change.
- Re-renders when the result of the selector changes.
- Should be used inside `Plate`.
- Must be used inside `Plate` or `PlateController` (see [Accessing the Editor](/docs/accessing-editor/)).

<APIParameters>
<APIItem name="selector" type="(editor: PlateEditor<V>, prev?: T) => T">
Expand Down Expand Up @@ -370,7 +370,7 @@ Get the Slate editor reference with re-rendering.

- Re-renders on editor change.
- Supports nested editors.
- Should be used inside `Plate`.
- Must be used inside `Plate` or `PlateController` (see [Accessing the Editor](/docs/accessing-editor/)).
- Note the reference does not change when the editor changes.
- If performance is a concern, `useEditorSelector` should be used instead.

Expand Down Expand Up @@ -400,6 +400,21 @@ The `readOnly` state of the editor.

</APIReturns>

### useEditorMounted

Get the editor's `isMounted` state.

<APIParameters>
<APIItem name="id" type="PlateId" optional>
The ID of the plate editor.
</APIItem>
</APIParameters>
<APIReturns>

The `isMounted` state of the editor.

</APIReturns>

### useEditorSelection

Get the editor's selection. Memoized so it does not re-render if the range is the same.
Expand Down
7 changes: 7 additions & 0 deletions apps/www/content/docs/api/core/plate.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ Controlled callback called when the editor state changes.

Plate plugins. See [Plugin](/docs/plugin) guide.

</APIItem>
<APIItem name="primary" type="boolean" optional>

Controls whether the editor is considered active by default when used with a PlateController. See [Accessing the Editor](/docs/accessing-editor#from-a-sibling-or-ancestor-of-plate).

- **Default:** **`true`**

</APIItem>
<APIItem name="value" type="Value" optional>

Expand Down
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@
"clsx": "^1.2.1",
"is-hotkey": "^0.2.0",
"jotai": "^2.6.0",
"jotai-x": "^1.1.0",
"jotai-optics": "0.3.1",
"jotai-x": "^1.2.1",
"lodash": "^4.17.21",
"nanoid": "^3.3.6",
"optics-ts": "2.4.1",
"react-hotkeys-hook": "^4.4.1",
"use-deep-compare": "^1.1.0",
"zustand": "^4.4.7",
Expand Down
96 changes: 82 additions & 14 deletions packages/core/src/components/Plate.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
usePlateSelectors,
} from '@udecode/plate-common';

import { PLATE_SCOPE } from '../stores/index';
import { PlateController, usePlateEditorStore } from '../stores';
import { Plate } from './Plate';

describe('Plate', () => {
Expand All @@ -27,24 +27,36 @@ describe('Plate', () => {
});
describe('when editor is not defined', () => {
it('should be default', async () => {
const wrapper = ({ children }: any) => <Plate>{children}</Plate>;
const wrapper = ({ children }: any) => (
<Plate id="test1">
<Plate id="test2">{children}</Plate>
</Plate>
);

const { result } = renderHook(() => useEditorRef(), {
wrapper,
});

expect(result.current.id).toBe(PLATE_SCOPE.toString());
expect(result.current.id).toBe('test2');
});
});
describe('when id is defined', () => {
it('should be id', async () => {
const wrapper = ({ children }: any) => (
<Plate id="test">{children}</Plate>
<Plate id="test1">
<Plate id="test2">{children}</Plate>
</Plate>
);
const { result } = renderHook(() => useEditorRef('test'), {

const { result: result1 } = renderHook(() => useEditorRef('test1'), {
wrapper,
});
const { result: result2 } = renderHook(() => useEditorRef('test2'), {
wrapper,
});

expect(result.current.id).toBe('test');
expect(result1.current.id).toBe('test1');
expect(result2.current.id).toBe('test2');
});
});
});
Expand Down Expand Up @@ -224,19 +236,75 @@ describe('Plate', () => {
expect(result.current).toBe('test');
});
});
});

describe('when Plate with id > Plate without id', () => {
it('should be that id', () => {
describe('usePlateEditorStore', () => {
const getStore = (wrapper: any) =>
renderHook(() => usePlateEditorStore(), { wrapper }).result.current;

const getId = (wrapper: any) =>
renderHook(() => usePlateSelectors().id(), { wrapper }).result.current;

const getIsFallback = (wrapper: any) =>
renderHook(() => useEditorRef().isFallback, { wrapper }).result.current;

describe('when Plate exists', () => {
it('returns the store', () => {
const wrapper = ({ children }: any) => (
<Plate id="test">
<Plate>{children}</Plate>
</Plate>
<Plate id="test">{children}</Plate>
);
const { result } = renderHook(() => usePlateSelectors().id(), {
wrapper,

expect(getStore(wrapper)).toBeDefined();
expect(getId(wrapper)).toBe('test');
expect(getIsFallback(wrapper)).toBe(false);
});
});

describe('when Plate does not exist', () => {
describe('when PlateController exists', () => {
describe('when PlateController returns a store', () => {
it('returns the store', () => {
const EXPECTED_STORE = 'expected store' as any;

const wrapper = ({ children }: any) => (
<PlateController
editorStores={{
test: EXPECTED_STORE,
}}
activeId="test"
>
{children}
</PlateController>
);

expect(getStore(wrapper)).toBe(EXPECTED_STORE);
});
});

expect(result.current).toBe(PLATE_SCOPE);
describe('when PlateController returns null', () => {
it('returns the fallback store', () => {
const wrapper = ({ children }: any) => (
<PlateController
editorStores={{
test: null,
}}
activeId="test"
>
{children}
</PlateController>
);

expect(getStore(wrapper)).toBeDefined();
expect(getIsFallback(wrapper)).toBe(true);
});
});
});

describe('when PlateController does not exist', () => {
it('throws an error', () => {
const wrapper = ({ children }: any) => <>{children}</>;
expect(() => getStore(wrapper)).toThrowError();
});
});
});
});
Expand Down
Loading

0 comments on commit 25ff6af

Please sign in to comment.