import * as React from 'react'; import { ReactNode, useEffect } from 'react'; import { FormHelperText } from '@mui/material'; import { styled } from '@mui/material/styles'; import { Color } from '@tiptap/extension-color'; import Highlight from '@tiptap/extension-highlight'; import Image from '@tiptap/extension-image'; import Link from '@tiptap/extension-link'; import TextAlign from '@tiptap/extension-text-align'; import TextStyle from '@tiptap/extension-text-style'; import Underline from '@tiptap/extension-underline'; import { Editor, EditorContent, EditorOptions, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import clsx from 'clsx'; import { useInput, useResourceContext } from 'ra-core'; import { CommonInputProps, InputHelperText, Labeled, LabeledProps, } from 'ra-ui-materialui'; import { RichTextInputToolbar } from './RichTextInputToolbar'; import { TiptapEditorProvider } from './TiptapEditorProvider'; /** * A rich text editor for the react-admin that is accessible and supports translations. Based on [Tiptap](https://www.tiptap.dev/). * @param props The input props. Accept all common react-admin input props. * @param {EditorOptions} props.editorOptions The options to pass to the Tiptap editor. See Tiptap settings [here](https://tiptap.dev/api/editor#settings). * @param {ReactNode} props.toolbar The toolbar containing the editors commands. * * @example Customizing the editors options * import { RichTextInput, RichTextInputToolbar } from 'ra-input-rich-text'; * const MyRichTextInput = (props) => ( * } * label="Body" * source="body" * {...props} * /> * ); * * @example Customizing the toolbar size * import { RichTextInput, RichTextInputToolbar } from 'ra-input-rich-text'; * const MyRichTextInput = (props) => ( * } * label="Body" * source="body" * {...props} * /> * ); * * @example Customizing the toolbar commands * import { RichTextInput, RichTextInputToolbar } from 'ra-input-rich-text'; * const MyRichTextInput = ({ size, ...props }) => ( * * * * * * * * * * * )} * label="Body" * source="body" * {...props} * /> * ); */ export const RichTextInput = (props: RichTextInputProps) => { const { className, defaultValue = '', disabled = false, editorOptions = DefaultEditorOptions, fullWidth = true, helperText, label, readOnly = false, source, sx, toolbar, } = props; const resource = useResourceContext(props); const { id, field, isRequired, fieldState, formState: { isSubmitted }, } = useInput({ ...props, source, defaultValue }); 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; useEffect(() => { if (!editor) return; const { from, to } = editor.state.selection; editor.commands.setContent(field.value, false, { preserveWhitespace: true, }); editor.commands.setTextSelection({ from, to }); }, [editor, field.value]); useEffect(() => { if (!editor) { return; } const handleEditorUpdate = () => { if (editor.isEmpty) { field.onChange(''); field.onBlur(); return; } const html = editor.getHTML(); field.onChange(html); field.onBlur(); }; editor.on('update', handleEditorUpdate); editor.on('blur', field.onBlur); return () => { editor.off('update', handleEditorUpdate); editor.off('blur', field.onBlur); }; }, [editor, field]); return ( } /> ); }; export const DefaultEditorOptions: Partial = { extensions: [ StarterKit, Underline, Link, TextAlign.configure({ types: ['heading', 'paragraph'], }), Image.configure({ inline: true, }), TextStyle, // Required by Color Color, Highlight.configure({ multicolor: true }), ], }; export type RichTextInputProps = CommonInputProps & Omit & { disabled?: boolean; readOnly?: boolean; editorOptions?: Partial; toolbar?: ReactNode; sx?: (typeof Root)['defaultProps']['sx']; }; const PREFIX = 'RaRichTextInput'; const classes = { editorContent: `${PREFIX}-editorContent`, }; const Root = styled('div', { name: PREFIX, overridesResolver: (props, styles) => styles.root, })(({ theme }) => ({ '&.fullWidth': { width: '100%', }, [`& .${classes.editorContent}`]: { width: '100%', '& .ProseMirror': { backgroundColor: (theme.vars || theme).palette.background.default, borderColor: (theme.vars || theme).palette.divider, borderRadius: theme.shape.borderRadius, borderStyle: 'solid', borderWidth: '1px', padding: theme.spacing(1), '&[contenteditable="false"], &[contenteditable="false"]:hover, &[contenteditable="false"]:focus': { backgroundColor: (theme.vars || theme).palette.action .disabledBackground, }, '&:hover': { backgroundColor: (theme.vars || theme).palette.action.hover, }, '&:focus': { backgroundColor: (theme.vars || theme).palette.background .default, }, '& p': { margin: '0 0 1em 0', '&:last-child': { marginBottom: 0, }, }, }, }, })); /** * Extracted in a separate component so that we can remove fullWidth from the props injected by Labeled * and avoid warnings about unknown props on Root. */ const RichTextInputContent = ({ editor, error, helperText, id, invalid, toolbar, }: RichTextInputContentProps) => ( <> {toolbar} ); export type RichTextInputContentProps = { className?: string; editor?: Editor; error?: any; helperText?: ReactNode; id: string; isTouched: boolean; isSubmitted: boolean; invalid: boolean; toolbar?: ReactNode; };