Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement PlateController #2859

Merged
merged 10 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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