Skip to content

Commit d73eb8e

Browse files
committed
feat: add ReactRenderer extension
1 parent 3e38618 commit d73eb8e

File tree

9 files changed

+358
-3
lines changed

9 files changed

+358
-3
lines changed

demo/Playground.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
useYfmEditor,
1515
YfmPreset,
1616
Extension,
17+
ReactRenderStorage,
18+
ReactRendererComponent,
1719
} from '../src';
1820
import {PlaygroundHtmlPreview} from './HtmlPreview';
1921
import {ProseMirrorDevTools} from './ProseMirrorDevTools';
@@ -49,6 +51,7 @@ const Playground = React.memo<PlaygroundProps>((props) => {
4951
const [yfmRaw, setYfmRaw] = React.useState<MarkupString>(initial || '');
5052
const rerender = useUpdate();
5153

54+
const renderStorage = React.useMemo(() => new ReactRenderStorage(), []);
5255
const extensions = React.useMemo<Extension>(
5356
() => (builder) =>
5457
builder
@@ -66,6 +69,7 @@ const Playground = React.memo<PlaygroundProps>((props) => {
6669
undoKey: keys.undo,
6770
redoKey: keys.redo,
6871
},
72+
reactRenderer: renderStorage,
6973
})
7074
.use(MarkdownBlocksPreset, {
7175
image: false,
@@ -84,7 +88,7 @@ const Playground = React.memo<PlaygroundProps>((props) => {
8488
code: {codeKey: keys.code},
8589
})
8690
.use(YfmPreset, {}),
87-
[breaks],
91+
[breaks, renderStorage],
8892
);
8993

9094
const editor = useYfmEditor({
@@ -167,7 +171,9 @@ const Playground = React.memo<PlaygroundProps>((props) => {
167171
</div>
168172
<hr />
169173
<div className={b('editor')}>
170-
<YfmEditorComponent editor={editor} autofocus className={b('editor')} />
174+
<YfmEditorComponent editor={editor} autofocus className={b('editor')}>
175+
<ReactRendererComponent storage={renderStorage} />
176+
</YfmEditorComponent>
171177
<ProseMirrorDevTools view={editor.view} />
172178
<PMSelection view={editor.view} className={b('pm-selection')} />
173179
</div>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {ReactRenderStorage} from './index';
2+
3+
describe('ReactRenderer extension', () => {
4+
describe('Storage', () => {
5+
it('should renturn empty items list', () => {
6+
const items = new ReactRenderStorage().getItems();
7+
expect(items).toHaveLength(0);
8+
});
9+
10+
it('should create renderer item', () => {
11+
const storage = new ReactRenderStorage();
12+
const item = storage.createItem('test', () => null);
13+
const items = storage.getItems();
14+
expect(items).toHaveLength(1);
15+
expect(items[0].id).toBe(item.id);
16+
});
17+
18+
it('should emit event after creating item', () => {
19+
const listener = jest.fn();
20+
const storage = new ReactRenderStorage();
21+
storage.on('update', listener);
22+
storage.createItem('test', () => null);
23+
expect(listener).toBeCalledTimes(1);
24+
});
25+
26+
it('should emit event after removal item', () => {
27+
const listener = jest.fn();
28+
const storage = new ReactRenderStorage();
29+
const item = storage.createItem('test', () => null);
30+
storage.on('update', listener);
31+
item.remove();
32+
expect(listener).toBeCalledTimes(1);
33+
});
34+
35+
it('should emit event after call item.rerender()', () => {
36+
const listener = jest.fn();
37+
const storage = new ReactRenderStorage();
38+
const item = storage.createItem('test', () => null);
39+
storage.getItems()[0].on('rerender', listener);
40+
item.rerender();
41+
expect(listener).toBeCalledTimes(1);
42+
});
43+
});
44+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {EditorState, Plugin, PluginKey} from 'prosemirror-state';
2+
import type {ExtensionAuto} from '../../../core';
3+
import {EventEmitter} from '../../../utils/event-emitter';
4+
import {
5+
Renderer,
6+
RendererItem,
7+
RenderStorage,
8+
RenderStorageEventMap,
9+
RenderStorageItem,
10+
RenderStorageItemEventMap,
11+
} from './types';
12+
13+
export type {RendererItem} from './types';
14+
export {Renderer as ReactRendererComponent} from './react';
15+
export type {RendererProps as ReactRendererComponentProps} from './react';
16+
17+
const key = new PluginKey<ReactRenderer>('reactrenderer');
18+
export function getReactRendererFromState(state: EditorState): ReactRenderer {
19+
const renderer = key.getState(state);
20+
if (!renderer) {
21+
throw new Error(
22+
'ReactRenderer is missing in the editor state. Probably, you forgot to use ReactRendererExtension',
23+
);
24+
}
25+
return renderer;
26+
}
27+
28+
export type ReactRenderer = Renderer<React.ReactNode>;
29+
export const ReactRendererExtension: ExtensionAuto<ReactRenderer> = (builder, renderer) => {
30+
builder.context.set('reactrenderer', renderer);
31+
builder.addPlugin(
32+
() =>
33+
new Plugin({
34+
key,
35+
state: {
36+
init: () => renderer,
37+
apply: () => renderer,
38+
},
39+
}),
40+
builder.Priority.Highest,
41+
);
42+
};
43+
44+
declare global {
45+
namespace YfmEditor {
46+
interface Context {
47+
reactrenderer: ReactRenderer;
48+
}
49+
}
50+
}
51+
52+
type RenderFn = () => React.ReactNode;
53+
interface ReactStorageItem extends RendererItem, RenderStorageItem<React.ReactNode> {}
54+
export class ReactRenderStorage
55+
extends EventEmitter<RenderStorageEventMap>
56+
implements ReactRenderer, RenderStorage<React.ReactNode>
57+
{
58+
private static Item = class Item
59+
extends EventEmitter<RenderStorageItemEventMap>
60+
implements ReactStorageItem
61+
{
62+
private static GlobalId = 0;
63+
private static getNextId(): number {
64+
return this.GlobalId++;
65+
}
66+
67+
readonly id: string;
68+
private readonly renderFn;
69+
private readonly removeFn;
70+
71+
constructor(idPrefix: string, renderFn: RenderFn, removeFn: (item: Item) => void) {
72+
super();
73+
this.id = idPrefix + Item.getNextId();
74+
this.renderFn = renderFn;
75+
this.removeFn = removeFn;
76+
}
77+
78+
render(): React.ReactNode {
79+
return this.renderFn();
80+
}
81+
82+
remove(): void {
83+
this.removeFn(this);
84+
}
85+
86+
rerender(): void {
87+
this.emit('rerender', null);
88+
}
89+
};
90+
91+
#items: ReactStorageItem[] = [];
92+
93+
getItems(): readonly RenderStorageItem<React.ReactNode>[] {
94+
return this.#items;
95+
}
96+
97+
createItem(idPrefix: string, render: RenderFn): RendererItem {
98+
const item = new ReactRenderStorage.Item(idPrefix, render, this.removeItem.bind(this));
99+
this.#items.push(item);
100+
this.emit('update', null);
101+
return item;
102+
}
103+
104+
removeItem(item: RendererItem): void;
105+
removeItem(id: string): void;
106+
removeItem(itemOrId: RendererItem | string): void {
107+
if (typeof itemOrId === 'string') {
108+
this.#items = this.#items.filter((item) => item.id !== itemOrId);
109+
} else {
110+
this.#items = this.#items.filter((item) => item !== itemOrId);
111+
}
112+
this.emit('update', null);
113+
}
114+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React, {memo, useEffect} from 'react';
2+
import {useUpdate} from 'react-use';
3+
import {RenderStorage, RenderStorageItem} from './types';
4+
5+
export type RendererProps = {
6+
storage: RenderStorage<React.ReactNode>;
7+
};
8+
9+
export const Renderer = memo<RendererProps>(({storage}) => {
10+
const update = useUpdate();
11+
useEffect(() => {
12+
storage.on('update', update);
13+
return () => {
14+
storage.off('update', update);
15+
};
16+
}, [storage, update]);
17+
18+
return (
19+
<>
20+
{storage.getItems().map((item) => (
21+
<ItemRenderer key={item.id} item={item} />
22+
))}
23+
</>
24+
);
25+
});
26+
Renderer.displayName = 'ReactRenderer';
27+
28+
type ItemRendererProps = {
29+
item: RenderStorageItem<React.ReactNode>;
30+
};
31+
32+
const ItemRenderer = memo<ItemRendererProps>(({item}) => {
33+
const update = useUpdate();
34+
useEffect(() => {
35+
item.on('rerender', update);
36+
return () => {
37+
item.off('rerender', update);
38+
};
39+
}, [item, update]);
40+
return <>{item.render()}</>;
41+
});
42+
ItemRenderer.displayName = 'ReactItemRenderer';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type {Receiver} from '../../../utils/event-emitter';
2+
3+
export interface Renderer<R> {
4+
createItem(idPrefix: string, render: () => R): RendererItem;
5+
removeItem(item: RendererItem): void;
6+
removeItem(id: string): void;
7+
}
8+
9+
export type RendererItem = {
10+
readonly id: string;
11+
rerender(): void;
12+
remove(): void;
13+
};
14+
15+
export type RenderStorageEventMap = {update: null};
16+
export interface RenderStorage<R> extends Receiver<RenderStorageEventMap> {
17+
getItems(): readonly RenderStorageItem<R>[];
18+
}
19+
20+
export type RenderStorageItemEventMap = {rerender: null};
21+
export interface RenderStorageItem<R> extends Receiver<RenderStorageItemEventMap> {
22+
readonly id: string;
23+
render(): R;
24+
}

src/extensions/behavior/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ import {Placeholder} from './Placeholder';
55
import {Cursor, CursorOptions} from './Cursor';
66
import {History, HistoryOptions} from './History';
77
import {Clipboard, ClipboardOptions} from './Clipboard';
8+
import {ReactRendererExtension, ReactRenderer} from './ReactRenderer';
89

910
export * from './Cursor';
1011
export * from './History';
1112
export * from './Clipboard';
1213
export * from './Selection';
1314
export * from './Placeholder';
15+
export * from './ReactRenderer';
1416

1517
export type BehaviorPresetOptions = {
1618
cursor?: CursorOptions;
1719
history?: HistoryOptions;
1820
clipboard?: ClipboardOptions;
21+
reactRenderer: ReactRenderer;
1922
};
2023

2124
export const BehaviorPreset: ExtensionAuto<BehaviorPresetOptions> = (builder, opts) => {
@@ -24,5 +27,6 @@ export const BehaviorPreset: ExtensionAuto<BehaviorPresetOptions> = (builder, op
2427
.use(Placeholder)
2528
.use(Cursor, opts.cursor ?? {})
2629
.use(History, opts.history ?? {})
27-
.use(Clipboard, opts.clipboard ?? {});
30+
.use(Clipboard, opts.clipboard ?? {})
31+
.use(ReactRendererExtension, opts.reactRenderer);
2832
};

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ export * from './table-utils';
2525

2626
export type {NodeChild} from './utils/nodes';
2727
export {getChildrenOfNode, getLastChildOfNode} from './utils/nodes';
28+
29+
export * from './utils/event-emitter';

src/utils/event-emitter.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {EventEmitter, SafeEventEmitter} from './event-emitter';
2+
3+
describe('EventEmitter', () => {
4+
it('should emit an event without listeners', () => {
5+
const emitter = new EventEmitter();
6+
emitter.emit('event', null);
7+
});
8+
9+
it('should emit an event for one listener', () => {
10+
const eventObj = {flag: false};
11+
const listener = jest.fn();
12+
const emitter = new EventEmitter();
13+
emitter.on('event0', listener);
14+
emitter.emit('event0', eventObj);
15+
expect(listener).toBeCalledTimes(1);
16+
expect(listener).toBeCalledWith(eventObj);
17+
});
18+
19+
it('should emit event for multiple listeners', () => {
20+
const eventObj = {test: 1};
21+
const listener0 = jest.fn();
22+
const listener1 = jest.fn();
23+
const emitter = new EventEmitter();
24+
emitter.on('event', listener0);
25+
emitter.on('event', listener1);
26+
emitter.emit('event', eventObj);
27+
expect(listener0).toBeCalledTimes(1);
28+
expect(listener1).toBeCalledTimes(1);
29+
expect(listener0).toBeCalledWith(eventObj);
30+
expect(listener1).toBeCalledWith(eventObj);
31+
});
32+
33+
it('should emit an event only for listeners subscribed to this type of event', () => {
34+
const listener0 = jest.fn();
35+
const listener1 = jest.fn();
36+
const emitter = new EventEmitter();
37+
emitter.on('event0', listener0);
38+
emitter.on('event1', listener1);
39+
emitter.emit('event0', null);
40+
expect(listener0).toBeCalled();
41+
expect(listener1).not.toBeCalled();
42+
});
43+
44+
it('should remove listeners', () => {
45+
const listener = jest.fn();
46+
const emitter = new EventEmitter();
47+
emitter.on('event', listener);
48+
emitter.off('event', listener);
49+
emitter.emit('event', null);
50+
expect(listener).not.toBeCalled();
51+
});
52+
53+
it('safe emitter should catch errors in listeners', () => {
54+
const onError = jest.fn();
55+
const emitter = new SafeEventEmitter({onError});
56+
emitter.on('event', () => {
57+
throw new Error('test error');
58+
});
59+
emitter.emit('event', {});
60+
expect(onError).toBeCalledWith(new Error('test error'));
61+
});
62+
});

0 commit comments

Comments
 (0)