Merge branch 'fix-mail-constant' into 'main'
[ProtonMail-WebClient.git] / applications / docs-editor / src / app / ContentEditable / ProtonContentEditableElement.tsx
blob74267933ca32f2f85d036a9e2a1bbafc52b629c8
1 import { KEY_DOWN_COMMAND, PASTE_COMMAND, type LexicalEditor } from 'lexical'
3 import type { Ref } from 'react'
4 import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
5 import { mergeRefs } from '../Shared/mergeRefs'
6 import { BEFOREINPUT_EVENT_COMMAND, COMPOSITION_START_EVENT_COMMAND, INPUT_EVENT_COMMAND } from '../Commands/Events'
7 import { eventFiles } from '@lexical/rich-text'
9 export type Props = {
10   editor: LexicalEditor
11   isSuggestionMode: boolean
12   ariaActiveDescendant?: React.AriaAttributes['aria-activedescendant']
13   ariaAutoComplete?: React.AriaAttributes['aria-autocomplete']
14   ariaControls?: React.AriaAttributes['aria-controls']
15   ariaDescribedBy?: React.AriaAttributes['aria-describedby']
16   ariaExpanded?: React.AriaAttributes['aria-expanded']
17   ariaLabel?: React.AriaAttributes['aria-label']
18   ariaLabelledBy?: React.AriaAttributes['aria-labelledby']
19   ariaMultiline?: React.AriaAttributes['aria-multiline']
20   ariaOwns?: React.AriaAttributes['aria-owns']
21   ariaRequired?: React.AriaAttributes['aria-required']
22   autoCapitalize?: HTMLDivElement['autocapitalize']
23   'data-testid'?: string | null | undefined
24 } & Omit<React.AllHTMLAttributes<HTMLDivElement>, 'placeholder'>
26 function ContentEditableElementImpl(
27   {
28     editor,
29     isSuggestionMode,
30     ariaActiveDescendant,
31     ariaAutoComplete,
32     ariaControls,
33     ariaDescribedBy,
34     ariaExpanded,
35     ariaLabel,
36     ariaLabelledBy,
37     ariaMultiline,
38     ariaOwns,
39     ariaRequired,
40     autoCapitalize,
41     className,
42     id,
43     role = 'textbox',
44     spellCheck = true,
45     style,
46     tabIndex,
47     'data-testid': testid,
48     ...rest
49   }: Props,
50   ref: Ref<HTMLDivElement>,
51 ): JSX.Element {
52   const [isEditable, setEditable] = useState(editor.isEditable())
54   const isSuggestionModeRef = useRef(isSuggestionMode)
55   useEffect(() => {
56     isSuggestionModeRef.current = isSuggestionMode
57   }, [isSuggestionMode])
59   const handleRef = useCallback(
60     (rootElement: null | HTMLElement) => {
61       if (rootElement) {
62         rootElement.addEventListener('compositionstart', (event) => {
63           editor.dispatchCommand(COMPOSITION_START_EVENT_COMMAND, event)
64         })
65         rootElement.addEventListener('keydown', (event) => {
66           if (isSuggestionModeRef.current) {
67             // We don't want to preventDefault keydown
68             // as that will also stop beforeinput events
69             // from being triggered.
70             event.stopImmediatePropagation()
71             editor.dispatchCommand(KEY_DOWN_COMMAND, event)
72           }
73         })
74         rootElement.addEventListener('input', (event) => {
75           if (isSuggestionModeRef.current) {
76             event.preventDefault()
77             event.stopImmediatePropagation()
78             editor.dispatchCommand(INPUT_EVENT_COMMAND, event)
79           }
80         })
81         rootElement.addEventListener('beforeinput', (event) => {
82           if (isSuggestionModeRef.current) {
83             event.preventDefault()
84             event.stopImmediatePropagation()
85             editor.dispatchCommand(BEFOREINPUT_EVENT_COMMAND, event)
86           }
87         })
88         rootElement.addEventListener('paste', (event) => {
89           const [, files, hasTextContent] = eventFiles(event)
90           if (isSuggestionModeRef.current && files.length > 0 && !hasTextContent) {
91             event.preventDefault()
92             event.stopImmediatePropagation()
93             editor.dispatchCommand(PASTE_COMMAND, event)
94           }
95         })
96       }
97       // defaultView is required for a root element.
98       // In multi-window setups, the defaultView may not exist at certain points.
99       if (rootElement && rootElement.ownerDocument && rootElement.ownerDocument.defaultView) {
100         editor.setRootElement(rootElement)
101       } else {
102         editor.setRootElement(null)
103       }
104     },
105     [editor],
106   )
107   const mergedRefs = useMemo(() => mergeRefs(ref, handleRef), [handleRef, ref])
109   useLayoutEffect(() => {
110     setEditable(editor.isEditable())
111     return editor.registerEditableListener((currentIsEditable) => {
112       setEditable(currentIsEditable)
113     })
114   }, [editor])
116   return (
117     // eslint-disable-next-line jsx-a11y/aria-activedescendant-has-tabindex
118     <div
119       {...rest}
120       aria-activedescendant={isEditable ? ariaActiveDescendant : undefined}
121       aria-autocomplete={isEditable ? ariaAutoComplete : 'none'}
122       aria-controls={isEditable ? ariaControls : undefined}
123       aria-describedby={ariaDescribedBy}
124       aria-expanded={isEditable && role === 'combobox' ? !!ariaExpanded : undefined}
125       aria-label={ariaLabel}
126       aria-labelledby={ariaLabelledBy}
127       aria-multiline={ariaMultiline}
128       aria-owns={isEditable ? ariaOwns : undefined}
129       aria-readonly={isEditable ? undefined : true}
130       aria-required={ariaRequired}
131       autoCapitalize={autoCapitalize}
132       className={className}
133       contentEditable={isEditable}
134       data-testid={testid}
135       id={id}
136       ref={mergedRefs}
137       role={isEditable ? role : undefined}
138       spellCheck={spellCheck}
139       style={style}
140       tabIndex={tabIndex}
141     />
142   )
145 export const ContentEditableElement = forwardRef(ContentEditableElementImpl)