From 74fdebfc18e91853e6021c4a12941ad2ee14fda3 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 18 Sep 2023 12:18:09 +0200 Subject: [PATCH 1/6] Fix RichTextInput does not update when its editorOptions prop changes --- packages/ra-input-rich-text/package.json | 3 + .../src/RichTextInput.stories.tsx | 241 +++++++++++++++++- .../ra-input-rich-text/src/RichTextInput.tsx | 45 +--- yarn.lock | 24 ++ 4 files changed, 277 insertions(+), 36 deletions(-) diff --git a/packages/ra-input-rich-text/package.json b/packages/ra-input-rich-text/package.json index fc834bea4c9..cf33f3bbb9d 100644 --- a/packages/ra-input-rich-text/package.json +++ b/packages/ra-input-rich-text/package.json @@ -49,6 +49,8 @@ "@mui/icons-material": "^5.0.1", "@mui/material": "^5.0.2", "@testing-library/react": "^11.2.3", + "@tiptap/extension-mention": "^2.0.3", + "@tiptap/suggestion": "^2.0.3", "data-generator-retail": "^4.14.4", "ra-core": "^4.14.4", "ra-data-fakerest": "^4.14.4", @@ -57,6 +59,7 @@ "react-dom": "^17.0.0", "react-hook-form": "^7.43.9", "rimraf": "^3.0.2", + "tippy.js": "^6.3.7", "typescript": "^5.1.3" }, "gitHead": "b227592132da6ae5f01438fa8269e04596cdfdd8" diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx index 3629d226523..84b7f6195ff 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx @@ -1,9 +1,33 @@ import * as React from 'react'; -import { I18nProvider, required } from 'ra-core'; -import { AdminContext, SimpleForm, SimpleFormProps } from 'ra-ui-materialui'; -import { RichTextInput } from './RichTextInput'; -import { RichTextInputToolbar } from './RichTextInputToolbar'; +import { + I18nProvider, + Resource, + required, + useGetManyReference, + useRecordContext, +} from 'ra-core'; +import { + AdminContext, + Edit, + PrevNextButtons, + SimpleForm, + SimpleFormProps, + TopToolbar, +} from 'ra-ui-materialui'; +import { Admin } from 'react-admin'; import { useWatch } from 'react-hook-form'; +import fakeRestDataProvider from 'ra-data-fakerest'; +import { MemoryRouter } from 'react-router-dom'; +import Mention from '@tiptap/extension-mention'; + +import { + DefaultEditorOptions, + RichTextInput, + RichTextInputProps, +} from './RichTextInput'; +import { RichTextInputToolbar } from './RichTextInputToolbar'; +import { ReactRenderer } from '@tiptap/react'; +import tippy from 'tippy.js'; export default { title: 'ra-input-rich-text/RichTextInput' }; @@ -145,3 +169,212 @@ export const Validation = (props: Partial) => ( ); + +const dataProvider = fakeRestDataProvider({ + posts: [ + { id: 1, body: 'Post 1' }, + { id: 2, body: 'Post 2' }, + { id: 3, body: 'Post 3' }, + ], + tags: [ + { id: 1, name: 'tag1', post_id: 1 }, + { id: 2, name: 'tag2', post_id: 1 }, + { id: 3, name: 'tag3', post_id: 2 }, + { id: 4, name: 'tag4', post_id: 2 }, + { id: 5, name: 'tag5', post_id: 3 }, + { id: 6, name: 'tag6', post_id: 3 }, + ], +}); + +const PostEdit = () => ( + + + + } + > + + + + +); + +const MyRichTextInput = (props: RichTextInputProps) => { + const record = useRecordContext(); + const tags = useGetManyReference('tags', { + target: 'post_id', + id: record.id, + }); + + const editorOptions = React.useMemo(() => { + return { + ...DefaultEditorOptions, + extensions: [ + ...DefaultEditorOptions.extensions, + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: suggestions(tags.data?.map(t => t.name) ?? []), + }), + ], + }; + }, [tags.data]); + + return ; +}; + +export const CustomOptions = () => ( + + + + + +); + +const MentionList = React.forwardRef< + any, + { + items: string[]; + command: (props: { id: string }) => void; + } +>((props, ref) => { + const [selectedIndex, setSelectedIndex] = React.useState(0); + + const selectItem = index => { + const item = props.items[index]; + + if (item) { + props.command({ id: item }); + } + }; + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length + ); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + React.useEffect(() => setSelectedIndex(0), [props.items]); + + React.useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === 'ArrowUp') { + upHandler(); + return true; + } + + if (event.key === 'ArrowDown') { + downHandler(); + return true; + } + + if (event.key === 'Enter') { + enterHandler(); + return true; + } + + return false; + }, + })); + + return ( +
+ {props.items.length ? ( + props.items.map((item, index) => ( + + )) + ) : ( +
No result
+ )} +
+ ); +}); + +const suggestions = tags => { + return { + items: ({ query }) => { + return tags + .filter(item => + item.toLowerCase().startsWith(query.toLowerCase()) + ) + .slice(0, 5); + }, + + render: () => { + let component; + let popup; + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + component.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup[0].hide(); + + return true; + } + + return component.ref?.onKeyDown(props); + }, + + onExit() { + if (popup && popup[0]) { + popup[0].destroy(); + } + if (component) { + component.destroy(); + } + }, + }; + }, + }; +}; diff --git a/packages/ra-input-rich-text/src/RichTextInput.tsx b/packages/ra-input-rich-text/src/RichTextInput.tsx index 5b67a6da2fa..f54cf3cf4c6 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.tsx @@ -97,18 +97,21 @@ export const RichTextInput = (props: RichTextInputProps) => { formState: { isSubmitted }, } = useInput({ ...props, source, defaultValue }); - const editor = useEditor({ - ...editorOptions, - editable: !disabled && !readOnly, - content: field.value, - editorProps: { - ...editorOptions?.editorProps, - attributes: { - ...editorOptions?.editorProps?.attributes, - id, + const editor = useEditor( + { + ...editorOptions, + editable: !disabled && !readOnly, + content: field.value, + editorProps: { + ...editorOptions?.editorProps, + attributes: { + ...editorOptions?.editorProps?.attributes, + id, + }, }, }, - }); + [disabled, editorOptions, readOnly, id] + ); const { error, invalid, isTouched } = fieldState; @@ -124,28 +127,6 @@ export const RichTextInput = (props: RichTextInputProps) => { editor.commands.setTextSelection({ from, to }); }, [editor, field.value]); - useEffect(() => { - if (!editor) return; - - editor.setOptions({ - editable: !disabled && !readOnly, - editorProps: { - ...editorOptions?.editorProps, - attributes: { - ...editorOptions?.editorProps?.attributes, - id, - }, - }, - }); - }, [ - disabled, - editor, - readOnly, - id, - editorOptions?.editorProps, - editorOptions?.editorProps?.attributes, - ]); - useEffect(() => { if (!editor) { return; diff --git a/yarn.lock b/yarn.lock index 5ebd0ba9dda..a94b8e0fc2b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5310,6 +5310,17 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-mention@npm:^2.0.3": + version: 2.1.10 + resolution: "@tiptap/extension-mention@npm:2.1.10" + peerDependencies: + "@tiptap/core": ^2.0.0 + "@tiptap/pm": ^2.0.0 + "@tiptap/suggestion": ^2.0.0 + checksum: 4299f11fb32214b7956aa588435360baaf282b70f9e5091bb3072c241d84873bc9b05c95a1e3b31901ea10e906eb3d34d2fd4199b9399b9f6e0d0240f66b4ffe + languageName: node + linkType: hard + "@tiptap/extension-ordered-list@npm:^2.0.3": version: 2.0.3 resolution: "@tiptap/extension-ordered-list@npm:2.0.3" @@ -5453,6 +5464,16 @@ __metadata: languageName: node linkType: hard +"@tiptap/suggestion@npm:^2.0.3": + version: 2.1.10 + resolution: "@tiptap/suggestion@npm:2.1.10" + peerDependencies: + "@tiptap/core": ^2.0.0 + "@tiptap/pm": ^2.0.0 + checksum: 0fec5b46a09ad481d5a6839913dba3b26eea8e9aba9efd1b02a4cc0e4b3dd1645d8d2e7b5f4c6c12772def5fa4628e0c3890c2500c4efa991cbcbab9f990a2aa + languageName: node + linkType: hard + "@tootallnate/once@npm:1": version: 1.1.2 resolution: "@tootallnate/once@npm:1.1.2" @@ -18385,6 +18406,7 @@ __metadata: "@tiptap/extension-highlight": ^2.0.3 "@tiptap/extension-image": ^2.0.3 "@tiptap/extension-link": ^2.0.3 + "@tiptap/extension-mention": ^2.0.3 "@tiptap/extension-placeholder": ^2.0.3 "@tiptap/extension-text-align": ^2.0.3 "@tiptap/extension-text-style": ^2.0.3 @@ -18392,6 +18414,7 @@ __metadata: "@tiptap/pm": ^2.0.3 "@tiptap/react": ^2.0.3 "@tiptap/starter-kit": ^2.0.3 + "@tiptap/suggestion": ^2.0.3 clsx: ^1.1.1 data-generator-retail: ^4.14.4 ra-core: ^4.14.4 @@ -18401,6 +18424,7 @@ __metadata: react-dom: ^17.0.0 react-hook-form: ^7.43.9 rimraf: ^3.0.2 + tippy.js: ^6.3.7 typescript: ^5.1.3 peerDependencies: "@mui/icons-material": ^5.0.1 From c0b8320fefdbeabd3f8d0a4f78b8e300d00a1afb Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 18 Sep 2023 15:49:53 +0200 Subject: [PATCH 2/6] Fix dev dependencies --- packages/ra-input-rich-text/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ra-input-rich-text/package.json b/packages/ra-input-rich-text/package.json index cf33f3bbb9d..6d33205bd31 100644 --- a/packages/ra-input-rich-text/package.json +++ b/packages/ra-input-rich-text/package.json @@ -56,6 +56,7 @@ "ra-data-fakerest": "^4.14.4", "ra-ui-materialui": "^4.14.4", "react": "^17.0.0", + "react-admin": "^4.14.1", "react-dom": "^17.0.0", "react-hook-form": "^7.43.9", "rimraf": "^3.0.2", From d1e32e16870036129bdd8be7861d9dccb5dd3288 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 18 Sep 2023 15:53:04 +0200 Subject: [PATCH 3/6] Update yarn.lock --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index a94b8e0fc2b..b8ff7157a19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18421,6 +18421,7 @@ __metadata: ra-data-fakerest: ^4.14.4 ra-ui-materialui: ^4.14.4 react: ^17.0.0 + react-admin: ^4.14.1 react-dom: ^17.0.0 react-hook-form: ^7.43.9 rimraf: ^3.0.2 From aa856767159b14a0b0e5d5c679a0fa102c263664 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 18 Sep 2023 16:15:41 +0200 Subject: [PATCH 4/6] Fix build --- packages/ra-input-rich-text/package.json | 1 - .../src/RichTextInput.stories.tsx | 42 ++++++++++--------- yarn.lock | 1 - 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/ra-input-rich-text/package.json b/packages/ra-input-rich-text/package.json index 6d33205bd31..cf33f3bbb9d 100644 --- a/packages/ra-input-rich-text/package.json +++ b/packages/ra-input-rich-text/package.json @@ -56,7 +56,6 @@ "ra-data-fakerest": "^4.14.4", "ra-ui-materialui": "^4.14.4", "react": "^17.0.0", - "react-admin": "^4.14.1", "react-dom": "^17.0.0", "react-hook-form": "^7.43.9", "rimraf": "^3.0.2", diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx index 84b7f6195ff..f6a9c18186f 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { I18nProvider, - Resource, required, useGetManyReference, useRecordContext, @@ -14,10 +13,9 @@ import { SimpleFormProps, TopToolbar, } from 'ra-ui-materialui'; -import { Admin } from 'react-admin'; import { useWatch } from 'react-hook-form'; import fakeRestDataProvider from 'ra-data-fakerest'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; import Mention from '@tiptap/extension-mention'; import { @@ -186,20 +184,6 @@ const dataProvider = fakeRestDataProvider({ ], }); -const PostEdit = () => ( - - - - } - > - - - - -); - const MyRichTextInput = (props: RichTextInputProps) => { const record = useRecordContext(); const tags = useGetManyReference('tags', { @@ -227,9 +211,27 @@ const MyRichTextInput = (props: RichTextInputProps) => { export const CustomOptions = () => ( - - - + + + + + + } + > + + + + + } + /> + + ); diff --git a/yarn.lock b/yarn.lock index b8ff7157a19..a94b8e0fc2b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18421,7 +18421,6 @@ __metadata: ra-data-fakerest: ^4.14.4 ra-ui-materialui: ^4.14.4 react: ^17.0.0 - react-admin: ^4.14.1 react-dom: ^17.0.0 react-hook-form: ^7.43.9 rimraf: ^3.0.2 From 70cadcf11960dda888df3ed88423e0a9a5651db0 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 19 Sep 2023 17:40:39 +0200 Subject: [PATCH 5/6] Fix story issues --- .../src/RichTextInput.stories.tsx | 98 ++++++++++++------- .../ra-input-rich-text/src/RichTextInput.tsx | 1 - 2 files changed, 64 insertions(+), 35 deletions(-) diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx index f6a9c18186f..879cf253290 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx @@ -17,15 +17,21 @@ import { useWatch } from 'react-hook-form'; import fakeRestDataProvider from 'ra-data-fakerest'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import Mention from '@tiptap/extension-mention'; - +import { ReactRenderer } from '@tiptap/react'; +import tippy, { Instance as TippyInstance } from 'tippy.js'; import { DefaultEditorOptions, RichTextInput, RichTextInputProps, } from './RichTextInput'; import { RichTextInputToolbar } from './RichTextInputToolbar'; -import { ReactRenderer } from '@tiptap/react'; -import tippy from 'tippy.js'; +import { + List, + ListItem, + ListItemButton, + ListItemText, + Paper, +} from '@mui/material'; export default { title: 'ra-input-rich-text/RichTextInput' }; @@ -236,7 +242,7 @@ export const CustomOptions = () => ( ); const MentionList = React.forwardRef< - any, + MentionListRef, { items: string[]; command: (props: { id: string }) => void; @@ -290,26 +296,32 @@ const MentionList = React.forwardRef< })); return ( -
- {props.items.length ? ( - props.items.map((item, index) => ( - - )) - ) : ( -
No result
- )} -
+ + + {props.items.length ? ( + props.items.map((item, index) => ( + selectItem(index)} + > + {item} + + )) + ) : ( + + No result + + )} + + ); }); +type MentionListRef = { + onKeyDown: (props: { event: React.KeyboardEvent }) => boolean; +}; const suggestions = tags => { return { items: ({ query }) => { @@ -321,8 +333,8 @@ const suggestions = tags => { }, render: () => { - let component; - let popup; + let component: ReactRenderer; + let popup: TippyInstance[]; return { onStart: props => { @@ -347,15 +359,19 @@ const suggestions = tags => { }, onUpdate(props) { - component.updateProps(props); + if (component) { + component.updateProps(props); + } if (!props.clientRect) { return; } - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); + if (popup && popup[0]) { + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + } }, onKeyDown(props) { @@ -365,16 +381,30 @@ const suggestions = tags => { return true; } - return component.ref?.onKeyDown(props); + if (!component.ref) { + return false; + } + + return component.ref.onKeyDown(props); }, onExit() { - if (popup && popup[0]) { - popup[0].destroy(); - } - if (component) { - component.destroy(); - } + queueMicrotask(() => { + if (popup && popup[0] && !popup[0].state.isDestroyed) { + popup[0].destroy(); + } + if (component) { + component.destroy(); + } + }); + + // Remove references to the old popup and component upon destruction/exit. + // (This should prevent redundant calls to `popup.destroy()`, which Tippy + // warns in the console is a sign of a memory leak, as the `suggestion` + // plugin seems to call `onExit` both when a suggestion menu is closed after + // a user chooses an option, *and* when the editor itself is destroyed.) + popup = undefined; + component = undefined; }, }; }, diff --git a/packages/ra-input-rich-text/src/RichTextInput.tsx b/packages/ra-input-rich-text/src/RichTextInput.tsx index f54cf3cf4c6..69950f52407 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.tsx @@ -123,7 +123,6 @@ export const RichTextInput = (props: RichTextInputProps) => { editor.commands.setContent(field.value, false, { preserveWhitespace: true, }); - editor.commands.setTextSelection({ from, to }); }, [editor, field.value]); From b7c315fc86dd19133c7a9e71bf6e6df5aa43319a Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 2 Oct 2023 12:55:53 +0200 Subject: [PATCH 6/6] Fix suggestions does not close after selection --- .../src/RichTextInput.stories.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx index 879cf253290..92ded1603de 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx @@ -375,7 +375,7 @@ const suggestions = tags => { }, onKeyDown(props) { - if (props.event.key === 'Escape') { + if (popup && popup[0] && props.event.key === 'Escape') { popup[0].hide(); return true; @@ -396,15 +396,14 @@ const suggestions = tags => { if (component) { component.destroy(); } + // Remove references to the old popup and component upon destruction/exit. + // (This should prevent redundant calls to `popup.destroy()`, which Tippy + // warns in the console is a sign of a memory leak, as the `suggestion` + // plugin seems to call `onExit` both when a suggestion menu is closed after + // a user chooses an option, *and* when the editor itself is destroyed.) + popup = undefined; + component = undefined; }); - - // Remove references to the old popup and component upon destruction/exit. - // (This should prevent redundant calls to `popup.destroy()`, which Tippy - // warns in the console is a sign of a memory leak, as the `suggestion` - // plugin seems to call `onExit` both when a suggestion menu is closed after - // a user chooses an option, *and* when the editor itself is destroyed.) - popup = undefined; - component = undefined; }, }; },