diff --git a/packages/picasso-forms/src/RichTextEditor/RichTextEditor.tsx b/packages/picasso-forms/src/RichTextEditor/RichTextEditor.tsx index 66bbbbc1b6..be082df661 100644 --- a/packages/picasso-forms/src/RichTextEditor/RichTextEditor.tsx +++ b/packages/picasso-forms/src/RichTextEditor/RichTextEditor.tsx @@ -10,6 +10,7 @@ import { useForm } from 'react-final-form' import type { FieldProps } from '../FieldWrapper' import InputField from '../InputField' import FieldLabel from '../FieldLabel' +import { useEnforceHighlightAutofill } from './hooks' type OverriddenProps = { defaultValue?: ASTType @@ -24,7 +25,10 @@ export type Props = RichTextEditorProps & type InternalProps = RichTextEditorProps & { value: string } export const RichTextEditor = (props: Props) => { - const { onChange, defaultValue, label, titleCase, ...rest } = props + const { onChange, onFocus, defaultValue, label, titleCase, ...rest } = props + + const { enforceHighlightAutofill, registerChangeOrFocus } = + useEnforceHighlightAutofill() const [value, setValue] = useState('') const { mutators: { setHasMultilineCounter }, @@ -34,17 +38,26 @@ export const RichTextEditor = (props: Props) => { // as an compatibility layer between final-form const handleOnChange = useCallback( (newVal: string) => { + registerChangeOrFocus() setValue(newVal) onChange?.(newVal) }, - [onChange, setValue] + [onChange, setValue, registerChangeOrFocus] ) + + const handleOnFocus = useCallback(() => { + registerChangeOrFocus() + + onFocus?.() + }, [onFocus, registerChangeOrFocus]) + const hiddenInputId = `${props.id}-hidden-input` return ( value={value} onChange={handleOnChange} + onFocus={handleOnFocus} label={ label ? ( { )} diff --git a/packages/picasso-forms/src/RichTextEditor/hooks/index.ts b/packages/picasso-forms/src/RichTextEditor/hooks/index.ts new file mode 100644 index 0000000000..dbcaaa26a9 --- /dev/null +++ b/packages/picasso-forms/src/RichTextEditor/hooks/index.ts @@ -0,0 +1 @@ +export { useEnforceHighlightAutofill } from './use-enforce-highlight-autofill' diff --git a/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.test.tsx b/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.test.tsx new file mode 100644 index 0000000000..87a315730a --- /dev/null +++ b/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.test.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { render, fireEvent } from '@toptal/picasso/test-utils' + +import { useEnforceHighlightAutofill } from './use-enforce-highlight-autofill' +import { useFormConfig } from '../../FormConfig' + +jest.mock('../../FormConfig', () => ({ + useFormConfig: jest.fn(), +})) + +const mockedUseFormConfig = useFormConfig as jest.MockedFunction< + typeof useFormConfig +> + +const TestComponent = () => { + const { enforceHighlightAutofill, registerChangeOrFocus } = + useEnforceHighlightAutofill() + + return ( + <> + +
+ {enforceHighlightAutofill ? 'true' : 'false'} +
+ + ) +} + +describe('useEnforceHighlightAutofill', () => { + describe('when form does not highlight autofilled fields', () => { + it('does not enforce highlighting', () => { + mockedUseFormConfig.mockReturnValue({ highlightAutofill: false }) + const { getByText, getByTestId } = render() + const button = getByText('trigger') + const autofillStatusContainer = getByTestId( + 'enforce-highlight-autofill-status' + ) + + fireEvent.click(button) + + // After first update + expect(autofillStatusContainer).toHaveTextContent('false') + }) + }) + + describe('when form highlights autofilled fields', () => { + it('highlighting is enforced after first update and not enforced after following updates', async () => { + mockedUseFormConfig.mockReturnValue({ highlightAutofill: true }) + const { getByText, getByTestId } = render() + + const button = getByText('trigger') + const autofillStatusContainer = getByTestId( + 'enforce-highlight-autofill-status' + ) + + fireEvent.click(button) + + // After first update + expect(autofillStatusContainer).toHaveTextContent('true') + + fireEvent.click(button) + + // After second update + expect(autofillStatusContainer).toHaveTextContent('false') + }) + }) +}) diff --git a/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.ts b/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.ts new file mode 100644 index 0000000000..09a885992d --- /dev/null +++ b/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.ts @@ -0,0 +1,48 @@ +import { useCallback, useMemo, useState } from 'react' + +import { useFormConfig } from '../../FormConfig' + +/** + * This hook keeps the highlighted state for Rich Text Editor form fields after + * first `onChange` callback which is triggered by editor itself when initial value is + * provided. Starting from second `onChange` or first `onFocus` invocation (which are + * triggered by user) the field is no longer highlighted. + * + * Editor triggers the very first onChange callback by itself only when initial value + * is provided in order to propagate the value to wrapping form. + */ + +/** + * After first two editor changes are tracked (the initial one and the one originated + * from user), hook stops responding due to performance reasons. + */ +const TRACKED_EVENTS_LIMIT = 2 + +export const useEnforceHighlightAutofill = () => { + const { highlightAutofill } = useFormConfig() + const [timesChangeOrFocusTriggered, setTimesChangeOrFocusTriggered] = + useState(0) + + const enforceHighlightAutofill = useMemo(() => { + if (!highlightAutofill) { + return false + } + + return timesChangeOrFocusTriggered < 2 + }, [highlightAutofill, timesChangeOrFocusTriggered]) + + const registerChangeOrFocus = useCallback(() => { + if (!highlightAutofill) { + return + } + + if (timesChangeOrFocusTriggered <= TRACKED_EVENTS_LIMIT) { + setTimesChangeOrFocusTriggered(timesChangeOrFocusTriggered + 1) + } + }, [highlightAutofill, timesChangeOrFocusTriggered]) + + return { + enforceHighlightAutofill, + registerChangeOrFocus, + } +} diff --git a/packages/picasso-rich-text-editor/package.json b/packages/picasso-rich-text-editor/package.json index 4b60d5c863..16b7bc7c62 100644 --- a/packages/picasso-rich-text-editor/package.json +++ b/packages/picasso-rich-text-editor/package.json @@ -45,7 +45,8 @@ "hast-util-from-dom": "^3.0.0", "hast-util-sanitize": "^3.0.2", "hast-util-to-html": "^7.1.3", - "lexical": "^0.9.1", + "hast-util-to-dom": "^3.1.1", + "lexical": "^0.9.2", "quill": "^1.3.7", "quill-emoji": "^0.2.0", "quill-paste-smart": "^1.4.9", diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/LexicalEditor.tsx b/packages/picasso-rich-text-editor/src/LexicalEditor/LexicalEditor.tsx index 1f681d3fe7..d489c9a752 100644 --- a/packages/picasso-rich-text-editor/src/LexicalEditor/LexicalEditor.tsx +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/LexicalEditor.tsx @@ -5,7 +5,6 @@ import { makeStyles } from '@material-ui/core/styles' import { LexicalComposer } from '@lexical/react/LexicalComposer' import type { InitialConfigType } from '@lexical/react/LexicalComposer' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin' import { ContentEditable } from '@lexical/react/LexicalContentEditable' import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary' @@ -15,8 +14,11 @@ import { noop } from '@toptal/picasso/utils' import { Container, Typography } from '@toptal/picasso' import { ListItemNode, ListNode } from '@lexical/list' import { $isRootTextContentEmpty } from '@lexical/text' +import type { LexicalEditor as LexicalEditorType } from 'lexical' +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' -import { createLexicalTheme } from './utils' +import { TriggerInitialOnChangePlugin } from './plugins' +import { createLexicalTheme, setEditorValue } from './utils' import { useTypographyClasses, useOnFocus } from './hooks' import styles from './styles' import type { ChangeHandler, TextLengthChangeHandler } from './types' @@ -24,6 +26,7 @@ import ToolbarPlugin from '../LexicalEditorToolbarPlugin' import LexicalTextLengthPlugin from '../LexicalTextLengthPlugin' import LexicalListPlugin from '../LexicalListPlugin' import LexicalHeadingsReplacementPlugin from '../LexicalHeadingsReplacementPlugin' +import type { ASTType } from '../RichText' const useStyles = makeStyles(styles, { name: 'LexicalEditor', @@ -37,7 +40,7 @@ export type Props = BaseProps & { /** Indicates that an element is to be focused on page load */ autoFocus?: boolean /** Default value in [HAST](https://github.com/syntax-tree/hast) format */ - // defaultValue?: ASTType + defaultValue?: ASTType /** * This Boolean attribute indicates that the user cannot interact with the control. */ @@ -84,7 +87,7 @@ const LexicalEditor = forwardRef(function LexicalEditor( const { // plugins, autoFocus = false, - // defaultValue, + defaultValue, disabled = false, id, onChange = noop, @@ -129,6 +132,11 @@ const LexicalEditor = forwardRef(function LexicalEditor( const editorConfig: InitialConfigType = useMemo( () => ({ + editorState: (editor: LexicalEditorType) => { + if (defaultValue) { + setEditorValue(editor, defaultValue) + } + }, theme, onError(error: Error) { throw error @@ -137,7 +145,7 @@ const LexicalEditor = forwardRef(function LexicalEditor( nodes: [ListNode, ListItemNode, HeadingNode], editable: !disabled, }), - [theme, disabled] + [defaultValue, theme, disabled] ) const handleChange = useCallback( @@ -170,6 +178,9 @@ const LexicalEditor = forwardRef(function LexicalEditor( // remount Toolbar when disabled key={`${disabled || !isFocused}`} /> + {defaultValue ? ( + + ) : null} {autoFocus && } diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/plugins/TriggerInitialOnChangePlugin.ts b/packages/picasso-rich-text-editor/src/LexicalEditor/plugins/TriggerInitialOnChangePlugin.ts new file mode 100644 index 0000000000..00d2118a19 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/plugins/TriggerInitialOnChangePlugin.ts @@ -0,0 +1,24 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import type { EditorState, LexicalEditor } from 'lexical' + +const TriggerInitialOnChangePlugin = ({ + onChange, +}: { + onChange: ( + editorState: EditorState, + editor: LexicalEditor, + tags: Set + ) => void +}) => { + const [editor] = useLexicalComposerContext() + + editor.registerUpdateListener(({ editorState, prevEditorState, tags }) => { + if (prevEditorState.isEmpty()) { + onChange(editorState, editor, tags) + } + }) + + return null +} + +export default TriggerInitialOnChangePlugin diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/plugins/index.ts b/packages/picasso-rich-text-editor/src/LexicalEditor/plugins/index.ts new file mode 100644 index 0000000000..515d4d0f3d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/plugins/index.ts @@ -0,0 +1 @@ +export { default as TriggerInitialOnChangePlugin } from './TriggerInitialOnChangePlugin' diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/utils/getDomValue.test.ts b/packages/picasso-rich-text-editor/src/LexicalEditor/utils/getDomValue.test.ts new file mode 100644 index 0000000000..e019fd5291 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/utils/getDomValue.test.ts @@ -0,0 +1,18 @@ +import type { ASTType } from '../../RichText' +import { getDomValue } from './getDomValue' + +describe('getDomValue', () => { + it('returns DOM structure from AST', () => { + const inputValue: ASTType = { + type: 'root', + children: [{ type: 'text', value: 'Example of default text' }], + } + + const result = getDomValue(inputValue) + const serializer = new XMLSerializer() + + expect(serializer.serializeToString(result)).toBe( + 'Example of default text' + ) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/utils/getDomValue.ts b/packages/picasso-rich-text-editor/src/LexicalEditor/utils/getDomValue.ts new file mode 100644 index 0000000000..81d55a8a48 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/utils/getDomValue.ts @@ -0,0 +1,10 @@ +import type { HastNode } from 'hast-util-to-dom/lib' +import toHtml from 'hast-util-to-html' + +import type { ASTType } from '../../RichText' + +export const getDomValue = (value: ASTType) => { + const parser = new DOMParser() + + return parser.parseFromString(toHtml(value as HastNode), 'text/html') +} diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/utils/index.ts b/packages/picasso-rich-text-editor/src/LexicalEditor/utils/index.ts index 375dc9d77c..0a07ac5ef9 100644 --- a/packages/picasso-rich-text-editor/src/LexicalEditor/utils/index.ts +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/utils/index.ts @@ -3,3 +3,5 @@ export { synchronizeToolbarState } from './synchronizeToolbarState' export { toolbarStateReducer } from './toolbarState' export { getLexicalNode } from './getLexicalNode' export { createLexicalTheme } from './createLexicalTheme' +export { getDomValue } from './getDomValue' +export { setEditorValue } from './setEditorValue' diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/utils/setEditorValue.ts b/packages/picasso-rich-text-editor/src/LexicalEditor/utils/setEditorValue.ts new file mode 100644 index 0000000000..a96e7c9989 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/utils/setEditorValue.ts @@ -0,0 +1,29 @@ +import type { LexicalEditor as LexicalEditorType } from 'lexical' +import { + $createParagraphNode, + $getRoot, + $isDecoratorNode, + $isElementNode, +} from 'lexical' +import { $generateNodesFromDOM } from '@lexical/html' + +import type { ASTType } from '../../RichText' +import { getDomValue } from './getDomValue' + +export const setEditorValue = (editor: LexicalEditorType, value: ASTType) => { + const domValue = getDomValue(value) + + const root = $getRoot() + const lexicalValueNodes = $generateNodesFromDOM(editor, domValue) + + lexicalValueNodes.forEach(node => { + if ($isElementNode(node) || $isDecoratorNode(node)) { + root.append(node) + } else { + const paragraphNode = $createParagraphNode() + + paragraphNode.append(node) + root.append(paragraphNode) + } + }) +} diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/RichTextEditor.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/RichTextEditor.tsx index 06ed17ee96..099d0e8cbf 100644 --- a/packages/picasso-rich-text-editor/src/RichTextEditor/RichTextEditor.tsx +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/RichTextEditor.tsx @@ -98,7 +98,7 @@ export const RichTextEditor = forwardRef( // plugins, autoFocus = false, className, - // defaultValue, + defaultValue, disabled, id, onChange = noop, @@ -187,6 +187,7 @@ export const RichTextEditor = forwardRef( testIds={testIds} disabled={disabled} autoFocus={autoFocus} + defaultValue={defaultValue} /> {hiddenInputId && ( // Native `for` attribute on label does not work for div target diff --git a/yarn.lock b/yarn.lock index 4255e40679..b8389dc89f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13101,6 +13101,14 @@ hast-util-sanitize@^3.0.2: dependencies: xtend "^4.0.0" +hast-util-to-dom@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/hast-util-to-dom/-/hast-util-to-dom-3.1.1.tgz#7cd9f925b0cff8ec2c95474ef896b1865fcefd62" + integrity sha512-hDiYqOapuWzLPDMADCD5z6re/07OQOpQuT2YO5hxPjaxWTtgcbjqCjlv4KtyMuEQiW4wKTIPoK+japvbZ5zqxg== + dependencies: + property-information "^6.0.0" + web-namespaces "^2.0.0" + hast-util-to-html@^7.1.3: version "7.1.3" resolved "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-7.1.3.tgz" @@ -15601,7 +15609,7 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -lexical@^0.9.1: +lexical@^0.9.2: version "0.9.2" resolved "https://registry.yarnpkg.com/lexical/-/lexical-0.9.2.tgz#b4e910322270a7ad06fc7e625066ffa43fa1d4fb" integrity sha512-8S3fNd053/QD5DJ4wePlAY4Rc5k2LFvg2lBcmKLpPUZ+1/1tr35Kdyr5wyFWJKy1rbEUqjP74Hvly/lcXSipag== @@ -23810,6 +23818,11 @@ web-namespaces@^1.0.0: resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz" integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== +web-namespaces@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" + integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"