Skip to content

Commit 69f63ca

Browse files
authored
Merge pull request #9289 from marmelab/fix-rich-text-input-editor-options-update
Fix RichTextInput does not update when its editorOptions prop changes
2 parents dc2625f + b7c315f commit 69f63ca

File tree

4 files changed

+308
-37
lines changed

4 files changed

+308
-37
lines changed

packages/ra-input-rich-text/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
"@mui/icons-material": "^5.0.1",
5050
"@mui/material": "^5.0.2",
5151
"@testing-library/react": "^11.2.3",
52+
"@tiptap/extension-mention": "^2.0.3",
53+
"@tiptap/suggestion": "^2.0.3",
5254
"data-generator-retail": "^4.14.4",
5355
"ra-core": "^4.14.4",
5456
"ra-data-fakerest": "^4.14.4",
@@ -57,6 +59,7 @@
5759
"react-dom": "^17.0.0",
5860
"react-hook-form": "^7.43.9",
5961
"rimraf": "^3.0.2",
62+
"tippy.js": "^6.3.7",
6063
"typescript": "^5.1.3"
6164
},
6265
"gitHead": "b227592132da6ae5f01438fa8269e04596cdfdd8"

packages/ra-input-rich-text/src/RichTextInput.stories.tsx

+268-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
11
import * as React from 'react';
2-
import { I18nProvider, required } from 'ra-core';
3-
import { AdminContext, SimpleForm, SimpleFormProps } from 'ra-ui-materialui';
4-
import { RichTextInput } from './RichTextInput';
5-
import { RichTextInputToolbar } from './RichTextInputToolbar';
2+
import {
3+
I18nProvider,
4+
required,
5+
useGetManyReference,
6+
useRecordContext,
7+
} from 'ra-core';
8+
import {
9+
AdminContext,
10+
Edit,
11+
PrevNextButtons,
12+
SimpleForm,
13+
SimpleFormProps,
14+
TopToolbar,
15+
} from 'ra-ui-materialui';
616
import { useWatch } from 'react-hook-form';
17+
import fakeRestDataProvider from 'ra-data-fakerest';
18+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
19+
import Mention from '@tiptap/extension-mention';
20+
import { ReactRenderer } from '@tiptap/react';
21+
import tippy, { Instance as TippyInstance } from 'tippy.js';
22+
import {
23+
DefaultEditorOptions,
24+
RichTextInput,
25+
RichTextInputProps,
26+
} from './RichTextInput';
27+
import { RichTextInputToolbar } from './RichTextInputToolbar';
28+
import {
29+
List,
30+
ListItem,
31+
ListItemButton,
32+
ListItemText,
33+
Paper,
34+
} from '@mui/material';
735

836
export default { title: 'ra-input-rich-text/RichTextInput' };
937

@@ -145,3 +173,239 @@ export const Validation = (props: Partial<SimpleFormProps>) => (
145173
</SimpleForm>
146174
</AdminContext>
147175
);
176+
177+
const dataProvider = fakeRestDataProvider({
178+
posts: [
179+
{ id: 1, body: 'Post 1' },
180+
{ id: 2, body: 'Post 2' },
181+
{ id: 3, body: 'Post 3' },
182+
],
183+
tags: [
184+
{ id: 1, name: 'tag1', post_id: 1 },
185+
{ id: 2, name: 'tag2', post_id: 1 },
186+
{ id: 3, name: 'tag3', post_id: 2 },
187+
{ id: 4, name: 'tag4', post_id: 2 },
188+
{ id: 5, name: 'tag5', post_id: 3 },
189+
{ id: 6, name: 'tag6', post_id: 3 },
190+
],
191+
});
192+
193+
const MyRichTextInput = (props: RichTextInputProps) => {
194+
const record = useRecordContext();
195+
const tags = useGetManyReference('tags', {
196+
target: 'post_id',
197+
id: record.id,
198+
});
199+
200+
const editorOptions = React.useMemo(() => {
201+
return {
202+
...DefaultEditorOptions,
203+
extensions: [
204+
...DefaultEditorOptions.extensions,
205+
Mention.configure({
206+
HTMLAttributes: {
207+
class: 'mention',
208+
},
209+
suggestion: suggestions(tags.data?.map(t => t.name) ?? []),
210+
}),
211+
],
212+
};
213+
}, [tags.data]);
214+
215+
return <RichTextInput editorOptions={editorOptions} {...props} />;
216+
};
217+
218+
export const CustomOptions = () => (
219+
<MemoryRouter initialEntries={['/posts/1']}>
220+
<AdminContext dataProvider={dataProvider}>
221+
<Routes>
222+
<Route
223+
path="/posts/:id"
224+
element={
225+
<Edit
226+
resource="posts"
227+
actions={
228+
<TopToolbar>
229+
<PrevNextButtons />
230+
</TopToolbar>
231+
}
232+
>
233+
<SimpleForm>
234+
<MyRichTextInput source="body" />
235+
</SimpleForm>
236+
</Edit>
237+
}
238+
/>
239+
</Routes>
240+
</AdminContext>
241+
</MemoryRouter>
242+
);
243+
244+
const MentionList = React.forwardRef<
245+
MentionListRef,
246+
{
247+
items: string[];
248+
command: (props: { id: string }) => void;
249+
}
250+
>((props, ref) => {
251+
const [selectedIndex, setSelectedIndex] = React.useState(0);
252+
253+
const selectItem = index => {
254+
const item = props.items[index];
255+
256+
if (item) {
257+
props.command({ id: item });
258+
}
259+
};
260+
261+
const upHandler = () => {
262+
setSelectedIndex(
263+
(selectedIndex + props.items.length - 1) % props.items.length
264+
);
265+
};
266+
267+
const downHandler = () => {
268+
setSelectedIndex((selectedIndex + 1) % props.items.length);
269+
};
270+
271+
const enterHandler = () => {
272+
selectItem(selectedIndex);
273+
};
274+
275+
React.useEffect(() => setSelectedIndex(0), [props.items]);
276+
277+
React.useImperativeHandle(ref, () => ({
278+
onKeyDown: ({ event }) => {
279+
if (event.key === 'ArrowUp') {
280+
upHandler();
281+
return true;
282+
}
283+
284+
if (event.key === 'ArrowDown') {
285+
downHandler();
286+
return true;
287+
}
288+
289+
if (event.key === 'Enter') {
290+
enterHandler();
291+
return true;
292+
}
293+
294+
return false;
295+
},
296+
}));
297+
298+
return (
299+
<Paper>
300+
<List dense disablePadding>
301+
{props.items.length ? (
302+
props.items.map((item, index) => (
303+
<ListItemButton
304+
dense
305+
selected={index === selectedIndex}
306+
key={index}
307+
onClick={() => selectItem(index)}
308+
>
309+
{item}
310+
</ListItemButton>
311+
))
312+
) : (
313+
<ListItem className="item" dense>
314+
<ListItemText>No result</ListItemText>
315+
</ListItem>
316+
)}
317+
</List>
318+
</Paper>
319+
);
320+
});
321+
322+
type MentionListRef = {
323+
onKeyDown: (props: { event: React.KeyboardEvent }) => boolean;
324+
};
325+
const suggestions = tags => {
326+
return {
327+
items: ({ query }) => {
328+
return tags
329+
.filter(item =>
330+
item.toLowerCase().startsWith(query.toLowerCase())
331+
)
332+
.slice(0, 5);
333+
},
334+
335+
render: () => {
336+
let component: ReactRenderer<MentionListRef>;
337+
let popup: TippyInstance[];
338+
339+
return {
340+
onStart: props => {
341+
component = new ReactRenderer(MentionList, {
342+
props,
343+
editor: props.editor,
344+
});
345+
346+
if (!props.clientRect) {
347+
return;
348+
}
349+
350+
popup = tippy('body', {
351+
getReferenceClientRect: props.clientRect,
352+
appendTo: () => document.body,
353+
content: component.element,
354+
showOnCreate: true,
355+
interactive: true,
356+
trigger: 'manual',
357+
placement: 'bottom-start',
358+
});
359+
},
360+
361+
onUpdate(props) {
362+
if (component) {
363+
component.updateProps(props);
364+
}
365+
366+
if (!props.clientRect) {
367+
return;
368+
}
369+
370+
if (popup && popup[0]) {
371+
popup[0].setProps({
372+
getReferenceClientRect: props.clientRect,
373+
});
374+
}
375+
},
376+
377+
onKeyDown(props) {
378+
if (popup && popup[0] && props.event.key === 'Escape') {
379+
popup[0].hide();
380+
381+
return true;
382+
}
383+
384+
if (!component.ref) {
385+
return false;
386+
}
387+
388+
return component.ref.onKeyDown(props);
389+
},
390+
391+
onExit() {
392+
queueMicrotask(() => {
393+
if (popup && popup[0] && !popup[0].state.isDestroyed) {
394+
popup[0].destroy();
395+
}
396+
if (component) {
397+
component.destroy();
398+
}
399+
// Remove references to the old popup and component upon destruction/exit.
400+
// (This should prevent redundant calls to `popup.destroy()`, which Tippy
401+
// warns in the console is a sign of a memory leak, as the `suggestion`
402+
// plugin seems to call `onExit` both when a suggestion menu is closed after
403+
// a user chooses an option, *and* when the editor itself is destroyed.)
404+
popup = undefined;
405+
component = undefined;
406+
});
407+
},
408+
};
409+
},
410+
};
411+
};

packages/ra-input-rich-text/src/RichTextInput.tsx

+13-33
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,21 @@ export const RichTextInput = (props: RichTextInputProps) => {
9797
formState: { isSubmitted },
9898
} = useInput({ ...props, source, defaultValue });
9999

100-
const editor = useEditor({
101-
...editorOptions,
102-
editable: !disabled && !readOnly,
103-
content: field.value,
104-
editorProps: {
105-
...editorOptions?.editorProps,
106-
attributes: {
107-
...editorOptions?.editorProps?.attributes,
108-
id,
100+
const editor = useEditor(
101+
{
102+
...editorOptions,
103+
editable: !disabled && !readOnly,
104+
content: field.value,
105+
editorProps: {
106+
...editorOptions?.editorProps,
107+
attributes: {
108+
...editorOptions?.editorProps?.attributes,
109+
id,
110+
},
109111
},
110112
},
111-
});
113+
[disabled, editorOptions, readOnly, id]
114+
);
112115

113116
const { error, invalid, isTouched } = fieldState;
114117

@@ -120,32 +123,9 @@ export const RichTextInput = (props: RichTextInputProps) => {
120123
editor.commands.setContent(field.value, false, {
121124
preserveWhitespace: true,
122125
});
123-
124126
editor.commands.setTextSelection({ from, to });
125127
}, [editor, field.value]);
126128

127-
useEffect(() => {
128-
if (!editor) return;
129-
130-
editor.setOptions({
131-
editable: !disabled && !readOnly,
132-
editorProps: {
133-
...editorOptions?.editorProps,
134-
attributes: {
135-
...editorOptions?.editorProps?.attributes,
136-
id,
137-
},
138-
},
139-
});
140-
}, [
141-
disabled,
142-
editor,
143-
readOnly,
144-
id,
145-
editorOptions?.editorProps,
146-
editorOptions?.editorProps?.attributes,
147-
]);
148-
149129
useEffect(() => {
150130
if (!editor) {
151131
return;

0 commit comments

Comments
 (0)