Skip to content

Commit

Permalink
docs: clarify the editor state management
Browse files Browse the repository at this point in the history
  • Loading branch information
petyosi committed Oct 28, 2023
1 parent 70e044d commit b5d8434
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 12 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/
.DS_Store
**/dist
**.tgz
docs/api
temp
etc
uploads
Expand Down
53 changes: 50 additions & 3 deletions docs/extending-the-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,58 @@ MDXEditor code base is built with extensibility in mind. In fact, even the core
MDXEditor uses a composable, graph-based reactive state management system internally. When initialized, the component creates multiple systems of stateful and stateless observables (called nodes) into a **realm**.
From there on, the React layer (properties, user input, etc) and the Lexical editor interact with the realm by publishing into certain nodes and or by subscribing to changes in node values.

Each editor plugin can specify a new set of nodes (called **system**) that can optionally interact with the existing set of systems (declared as dependencies). A good (yet not-so-complex) example of [such system is the diff-source plugin](https://github.com/mdx-editor/editor/blob/plugins/src/plugins/diff-source/index.tsx), that interacts with the core system to change the value of the 'markdown' node when the user edits the content in source mode.
Each editor plugin can declare a set of nodes (called **system**) and their interactions. The new nodes can optionally interact with the existing nodes if you declare the built-in systems as dependencies of the system. A good (yet not-so-complex) example of [such system is the diff-source plugin](https://github.com/mdx-editor/editor/blob/plugins/src/plugins/diff-source/index.tsx), that interacts with the core system to change the value of the 'markdown' node when the user edits the content in source mode.

The state management systems are strongly typed. When you declare one as a dependency, the injected nodes will be available to you with strict TypeScript types defined.

The example below illustrates how state management systems work in practice:
```tsx
import { realmPlugin, system } from '@mdxeditor/editor'

// The r(realm) parameter passed to the system constructor is the realm instance that is used
// to declare new nodes, and to connect existing nodes with operators.
// The operators are similar to the RxJS operators.
const mySystem = system((r) => {
// declare a stateful node that holds a string value.
const myNode = r.node("")
// This is a stateless node - it can be used as a signal pipe to pass values that trigger events in the system.
const mySignal = r.node<number>()

// connect the signal node to the stateful node using the `pipe` operator.
// The pipe operator will execute the callback whenever the signal node changes.
r.link(r.pipe(mySignal, r.o.map(v => `mySignal has been called ${v} times`)), myNode)

// Finally, export the nodes that should be accessible from outside (like the React components, for example).
return {
myNode,
mySignal
}
// the empty array below is the list of dependencies.
}, [])

// We can construct a new system that interacts with the nodes from the system above.
// The system constructor receives a tuple of dependencies, where the first element is the realm instance,
// and the second element is the list of nodes exported by the dependency systems.
const myOtherSystem = system((r, [{myNode}]) => {
// declare a stateful node that holds a string value.
const myOtherNode = r.node("")

// connect the stateful node to the stateful node from the other system.
r.link(myNode, myOtherNode)

return {
myOtherNode
}
}, [mySystem])
```

Following the approach above, you can access the built-in state management systems of the package. The most important one being the `coreSystem` - it includes stateful nodes like the `rootEditor` (the Lexical instance), `activeEditor` (can be the root editor or one of the nested editors). It also exposes convenient signals like `createRootEditorSubscription` and `createActiveEditorSubscription` that let you [hook up to the Lexical editor commands](https://lexical.dev/docs/concepts/commands#editorregistercommand).

Most of the plugin systems also expose signal nodes that let you insert certain node types into the editor. For example, the `codeBlockSystem` has a node `insertCodeBlockNode` that can be used to insert a code block into the editor.

## Accessing the state from React

In addition to the plugin function itself, the `realmPlugin` function returns a set of React hooks (conventionally named `certainPluginHooks`) that let you interact with the nodes declared in the plugin system and its dependencies. The hooks return the node values or functions that can publish into certain nodes. The next example is taken from the diff-source plugin Toolbar item:
The `realmPlugin` call returns the plugin itself and a set of React hooks (by convention, named `<plugin>Hooks`) that let you interact with the nodes declared in the plugin system and its dependencies. The hooks return the node values or functions that can be used to publish into certain nodes. The next example is taken from the diff-source plugin Toolbar item:

```tsx
export const DiffSourceToggleWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
Expand Down Expand Up @@ -59,7 +106,7 @@ While using strings for the nodes, the hooks have strict TypeScript typings, so

## Markdown / Editor state conversion

In its `init` method a plugin can specify a set of MDAST/Lexical **visitors** that will be used to convert the markdown source into the editor state and vice versa.
In its `init` method, a plugin can specify a set of MDAST/Lexical **visitors** that will be used to convert the markdown source into the editor state and vice versa.
The visitors are plugged into the core system visitors node and then used for processing the markdown input/output.
The easiest way for you to get a grip of the mechanism is to take a look at the [core plugin visitors](https://github.com/mdx-editor/editor/tree/main/src/plugins/core), that are used to process the basic nodes like paragraphs, bold, italic, etc. The registration of each visitor looks like this (excerpt from the `core` plugin):

Expand Down
104 changes: 95 additions & 9 deletions src/gurx/realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ export function realm() {
return new Set(nodes.map((s) => s.key))
}

/**
* A low-level utility that connects multiple nodes to a sink node with a map function.
* The nodes can be active (sources) or passive (pulls).
*/
function connect<T extends unknown[] = unknown[]>({ sources, pulls = [], map, sink: { key: sink } }: RealmProjectionSpec<T>) {
const dependency: RealmProjection<T> = {
map,
Expand All @@ -322,14 +326,6 @@ export function realm() {
executionMaps.clear()
}

function debug() {
const obj = {} as Record<string, unknown>
Object.entries(labels).forEach(([name, value]) => {
obj[name] = state.get(value.key)
})
// console.table(obj)
}

function pub<T1>(...args: [RN<T1>, T1]): void
function pub<T1, T2>(...args: [RN<T1>, T1, RN<T2>, T2]): void
function pub<T1, T2, T3>(...args: [RN<T1>, T1, RN<T2>, T2, RN<T3>, T3]): void
Expand Down Expand Up @@ -383,16 +379,31 @@ export function realm() {
}) as unknown as NodesFromValues<T>
}

/**
* Links the output of a node to another node.
*/
function link<T>(source: RealmNode<T>, sink: RealmNode<T>) {
connect({ map: (done) => (value) => done(value), sink, sources: [source] })
}

/**
* Constructs a new stateful node from an existing source.
* The source can be node(s) that get transformed from a set of operators.
* @example
* ```tsx
* const a = r.node(1)
* const b = r.derive(r.pipe(a, r.o.map((v) => v * 2)), 2)
* ```
*/
function derive<T>(source: RealmNode<T>, initial: T) {
return tap(node(initial, true), (sink) => {
connect({ map: (done) => (value) => done(value), sink, sources: [source] })
})
}

/**
* Operator that maps a the value of a node to a new node with a projection function.
*/
function map<I, O>(mapFunction: (value: I) => O) {
return ((source: RealmNode<I>) => {
const sink = node<O>()
Expand All @@ -407,6 +418,9 @@ export function realm() {
}) as Operator<I, O>
}

/**
* Operator that maps the output of a node to a fixed value.
*/
function mapTo<I, O>(value: O) {
return ((source: RealmNode<I>) => {
const sink = node<O>()
Expand All @@ -415,6 +429,10 @@ export function realm() {
}) as Operator<I, O>
}

/**
* Operator that filters the output of a node.
* If the predicate returns false, the emission is canceled.
*/
function filter<I, O = I>(predicate: (value: I) => boolean) {
return ((source: RealmNode<I>) => {
const sink = node<O>()
Expand All @@ -423,6 +441,10 @@ export function realm() {
}) as Operator<I, O>
}

/**
* Operator that captures the first emitted value of a node.
* Useful if you want to execute a side effect only once.
*/
function once<I>() {
return ((source: RealmNode<I>) => {
const sink = node<I>()
Expand All @@ -442,6 +464,10 @@ export function realm() {
}) as Operator<I, I>
}

/**
* Operator that runs with the latest and the current value of a node.
* Works like the {@link https://rxjs.dev/api/operators/scan | RxJS scan operator}.
*/
function scan<I, O>(accumulator: (current: O, value: I) => O, seed: O) {
return ((source: RealmNode<I>) => {
const sink = node<O>()
Expand All @@ -450,6 +476,9 @@ export function realm() {
}) as Operator<I, O>
}

/**
* Throttles the output of a node with the specified delay.
*/
function throttleTime<I>(delay: number) {
return ((source: RealmNode<I>) => {
const sink = node<I>()
Expand All @@ -473,6 +502,9 @@ export function realm() {
}) as Operator<I, I>
}

/**
* Delays the output of a node with `queueMicrotask`.
*/
function delayWithMicrotask<I>() {
return ((source: RealmNode<I>) => {
const sink = node<I>()
Expand All @@ -481,6 +513,9 @@ export function realm() {
}) as Operator<I, I>
}

/**
* Debounces the output of a node with the specified delay.
*/
function debounceTime<I>(delay: number) {
return ((source: RealmNode<I>) => {
const sink = node<I>()
Expand All @@ -503,6 +538,9 @@ export function realm() {
}) as Operator<I, I>
}

/**
* Buffers the stream of a node until the passed note emits.
*/
function onNext<I, O>(bufNode: RN<O>) {
return ((source: RealmNode<I>) => {
const sink = node<O>()
Expand All @@ -522,6 +560,10 @@ export function realm() {
}) as Operator<I, [I, O]>
}

/**
* Conditionally passes the stream of a node only if the passed note
* has emitted before a certain duration (in seconds).
*/
function passOnlyAfterNodeHasEmittedBefore<I>(starterNode: RN<unknown>, durationNode: RN<number>) {
return (source: RealmNode<I>) => {
const sink = node<I>()
Expand All @@ -540,6 +582,10 @@ export function realm() {
}
}

/**
* Pulls the latest values from the passed nodes.
* Note: The operator does not emit when the nodes emit. If you want to get that, use the `combine` function.
*/
function withLatestFrom<I, T1>(...nodes: [RN<T1>]): (source: RN<I>) => RN<[I, T1]> // prettier-ignore
function withLatestFrom<I, T1, T2>(...nodes: [RN<T1>, RN<T2>]): (source: RN<I>) => RN<[I, T1, T2]> // prettier-ignore
function withLatestFrom<I, T1, T2, T3>(...nodes: [RN<T1>, RN<T2>, RN<T3>]): (source: RN<I>) => RN<[I, T1, T2, T3]> // prettier-ignore
Expand All @@ -564,6 +610,21 @@ export function realm() {
}
}

/**
* Combines the values from multiple nodes into a single node
* that emits an array of the latest values the nodes.
* When one of the source nodes emits a value, the combined node
* emits an array of the latest values from each node.
* @example
* ```tsx
* const a = r.node(1)
* const b = r.node(2)
* const ab = r.combine(a, b)
* r.sub(ab, ([a, b]) => console.log(a, b))
* r.pub(a, 2)
* r.pub(b, 3)
* ```
*/
function combine<T1>(...nodes: [RN<T1>]): RN<T1> // prettier-ignore
function combine<T1, T2>(...nodes: [RN<T1>, RN<T2>]): RN<[T1, T2]> // prettier-ignore
function combine<T1, T2, T3>(...nodes: [RN<T1>, RN<T2>, RN<T3>]): RN<[T1, T2, T3]> // prettier-ignore
Expand Down Expand Up @@ -592,20 +653,35 @@ export function realm() {
return sink
}

/**
* Subscribes the passed callback to the output of a node.
* @param key - the key of the node.
*/
function subKey(key: string, subscription: Subscription<unknown>): UnsubscribeHandle {
return sub(labels[key], subscription)
}

/**
* Subscribes the passed callback to the output of multiple nodes.
* @param keys - the keys of the nodes.
*/
function subKeys(keys: string[], subscription: Subscription<unknown>): UnsubscribeHandle {
const nodes = keys.map((key) => labels[key])
// @ts-expect-error why?
return sub(...nodes.concat(subscription))
}

/**
* Publishes the passed value to the output of a node.
* @param key - the key of the node.
*/
function pubKey(key: string, value: unknown) {
pubKeys({ [key]: value })
}

/**
* Publishes a set of values to the output of multiple nodes.
*/
function pubKeys(values: Record<string, unknown>) {
const valuesWithInternalKeys = Object.entries(values).reduce(
(acc, [key, value]) =>
Expand All @@ -622,14 +698,25 @@ export function realm() {
pubIn(valuesWithInternalKeys)
}

/**
* Gets the current value of a node. The node must be stateful.
* @param key - the key of the node.
*/
function getKeyValue(key: string) {
return state.get(labels[key].key)
}

/**
* Gets the current value of a node. The node must be stateful.
* @param node - the node instance.
*/
function getValue<T>(node: RN<T>): T {
return state.get(node.key) as T
}

/**
* Gets the current values of multiple nodes. The nodes must be stateful.
*/
function getKeyValues(keys: string[]) {
return keys.map((key) => {
const label = labels[key]
Expand All @@ -643,7 +730,6 @@ export function realm() {
return {
combine,
connect,
debug,
derive,
getKeyValue,
getValue,
Expand Down
16 changes: 16 additions & 0 deletions src/test/gurx/pipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,20 @@ describe('pipe', () => {
r.pub(d, 7)
expect(spy).toHaveBeenCalledWith([3, 4, 7])
})

it('derives node values', () => {
const r = realm()
const a = r.node<number>(0)
const b = r.derive(
r.pipe(
a,
r.o.map((val) => val * 2)
),
2
)
const spy = vi.fn()
r.sub(b, spy)
r.pub(a, 3)
expect(spy).toHaveBeenCalledWith(6)
})
})

0 comments on commit b5d8434

Please sign in to comment.