UNPKG

@wix/design-system

Version:

@wix/design-system

300 lines 13.7 kB
import React from 'react'; import { Editor, EditorState, SelectionState } from 'draft-js'; import EditorUtilities from './EditorUtilities'; import { sizeTypes, inputToTagsSize, dataHooks } from './constants'; import { st, classes, vars } from './VariableInput.st.css.js'; import StatusIndicator from '../StatusIndicator'; import { StatusContext, getStatusFromContext, } from '../FormField/StatusContext'; /** * Error Boundary to catch Draft.js crashes on mobile devices with IME/autocorrect * * Known Issue: Draft.js has a fundamental incompatibility with Android's Input Method Editor (IME). * When Android autocorrect modifies the DOM directly (e.g., on SPACE key press), Draft.js attempts * to reconcile its virtual state with DOM nodes that have already been removed/modified by the IME, * resulting in "Failed to execute 'removeChild' on 'Node'" errors. * * This Error Boundary prevents the entire component from crashing and allows it to auto-recover. * While console warnings will still appear, the user experience remains functional. * * TODO: After Draft-JS migration, make sure this error is no longer thrown and then remove this error boundary. */ class EditorErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, errorCount: 0 }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, errorInfo) { console.warn('VariableInput: Draft.js error caught (likely mobile IME conflict):', error.message); } componentDidUpdate(prevProps, prevState) { // Auto-recover with max retry limit to prevent infinite loops if (this.state.hasError && this.state.errorCount < 5) { this.setState({ hasError: false, errorCount: this.state.errorCount + 1 }); } } render() { return this.props.children; } } /** Input with variables as tags */ class VariableInput extends React.PureComponent { constructor(props) { super(props); this._handleCompositionStart = () => { this.isComposing = true; }; this._handleCompositionUpdate = e => { // During IME composition, manually trigger onChange with current DOM text // This fixes Android autosuggestion issue where draft-js doesn't fire onChange if (this.props.onChange && e.target) { const text = e.target.textContent || ''; this.props.onChange(text); } }; this._handleCompositionEnd = () => { this.isComposing = false; }; this._handleInput = e => { if (this.isComposing && this.props.onChange && e.target) { const text = e.target.textContent || ''; this.props.onChange(text); } }; this._handlePastedText = (text, html, editorState) => { /** We need to prevent new line when `multilne` is false, * here we are removing any new lines while pasting text */ if (/\r|\n/.exec(text)) { text = text.replace(/(\r\n|\n|\r)/gm, ''); this._onEditorChange(EditorUtilities.insertText(editorState, text)); return true; } return false; }; this._isEmpty = () => this.state.editorState.getCurrentContent().getPlainText().length === 0; this._inputToTagSize = inputSize => { return inputToTagsSize[inputSize] || VariableInput.defaultProps.size; }; this._toString = () => { const { variableTemplate: { prefix, suffix }, } = this.props; const { editorState } = this.state; return EditorUtilities.convertToString({ editorState, prefix, suffix, }); }; this._onBlur = () => { const { onBlur = () => { } } = this.props; onBlur(this._toString()); }; this._onMouseDown = () => { this._isMouseDown = true; }; this._onMouseUp = () => { this._isMouseDown = false; }; this._onFocus = () => { const { onFocus = () => { } } = this.props; onFocus(this._toString()); }; this._onSubmit = () => { const { onSubmit = () => { } } = this.props; onSubmit(this._toString()); }; this._onChange = () => { const { onChange = () => { } } = this.props; // Only call onChange if not composing (during composition, manual handlers will call it) if (!this.isComposing) { onChange(this._toString()); } }; this._onEditorChange = editorState => { const prevHasFocus = this.state.editorState.getSelection().getHasFocus(); const newHasFocus = editorState.getSelection().getHasFocus(); const becomesFocused = !prevHasFocus && newHasFocus; const isKeyboard = !this._isMouseDown; if (becomesFocused && isKeyboard) { const selectionAtEnd = EditorState.moveSelectionToEnd(editorState).getSelection(); editorState = EditorState.forceSelection(editorState, selectionAtEnd); } this._setEditorState(editorState); }; this._setEditorState = (editorState, onStateChanged = () => { }) => { const { editorState: editorStateBefore } = this.state; const { variableTemplate: { prefix, suffix }, } = this.props; let updateEditorState = EditorUtilities.moveToEdge(editorState); let triggerCallback = () => { }; if (EditorUtilities.isBlured(editorStateBefore, updateEditorState)) { // onChange is called after the editor blur handler // and we can't reflect the changes there, we moved the logic here. triggerCallback = this._onBlur; if (EditorUtilities.hasUnparsedEntity(updateEditorState, prefix, suffix)) { updateEditorState = this._stringToContentState(EditorUtilities.convertToString({ editorState: updateEditorState, prefix, suffix, })); } } else if (EditorUtilities.isContentChanged(editorStateBefore, updateEditorState)) { triggerCallback = this._onChange; } this.setState({ editorState: updateEditorState }, () => { triggerCallback(); onStateChanged(); }); }; this._stringToContentState = str => { const { variableParser = () => { }, variableTagPropsParser, variableTemplate: { prefix, suffix }, } = this.props; const { editorState } = this.state; const content = EditorUtilities.stringToContentState({ str, variableParser, variableTagPropsParser, prefix, suffix, }); return EditorUtilities.pushAndKeepSelection({ editorState, content, }); }; this._setStringValue = (str, afterUpdated = () => { }) => { const updatedEditorState = EditorState.moveSelectionToEnd(this._stringToContentState(str)); this._setEditorState(updatedEditorState, () => { afterUpdated(updatedEditorState); }); }; /** Set value to display in the input */ this.setValue = value => { this._setStringValue(value, () => { this._onSubmit(); }); }; /** Insert variable at the input cursor position */ this.insertVariable = value => { const { variableParser, variableTagPropsParser, variableTemplate: { prefix, suffix }, } = this.props; const { editorState } = this.state; const text = variableParser(value); const tagProps = variableTagPropsParser(value); const newState = text ? EditorUtilities.insertEntity(editorState, { text, value, tagProps }) : EditorUtilities.insertText(editorState, `${prefix}${value}${suffix} `); this._setEditorState(newState, () => { this._onSubmit(); }); }; /** Insert Text at the input cursor position */ this.insertText = value => { const { editorState } = this.state; const newState = EditorUtilities.insertText(editorState, value); this._setEditorState(newState, () => { this._onSubmit(); }); }; /** * Focus the input at the end of the content. * Optionally, if variableKey is provided, focus after that specific variable. */ this.focus = ({ variableKey } = {}) => { const { editorState } = this.state; let selectionToFocus; if (variableKey) { const targetRange = EditorUtilities.findEntityRangeByKey(editorState, variableKey); if (targetRange) { const { blockKey, end } = targetRange; selectionToFocus = new SelectionState({ anchorKey: blockKey, anchorOffset: end, focusKey: blockKey, focusOffset: end, }); } } if (!selectionToFocus) { selectionToFocus = EditorState.moveSelectionToEnd(editorState).getSelection(); } const newEditorState = EditorState.forceSelection(editorState, selectionToFocus.merge({ hasFocus: true })); this.setState({ editorState: newEditorState }, () => { this._onFocus(); }); }; const { size, disabled } = props; const decorator = EditorUtilities.decoratorFactory({ tag: { size: this._inputToTagSize(size), disabled }, }); this.state = { editorState: EditorState.createEmpty(decorator), }; this.editorRef = React.createRef(); // IME composition tracking for Android autosuggestions this.isComposing = false; // Track mouse-initiated focus to avoid overriding click cursor position this._isMouseDown = false; } componentDidMount() { const { initialValue } = this.props; this._setStringValue(initialValue); // Add IME composition event listeners to handle Android autosuggestions if (this.editorRef.current) { const editorNode = this.editorRef.current.editor; if (editorNode) { editorNode.addEventListener('compositionstart', this._handleCompositionStart); editorNode.addEventListener('compositionupdate', this._handleCompositionUpdate); editorNode.addEventListener('compositionend', this._handleCompositionEnd); editorNode.addEventListener('input', this._handleInput); } } } componentWillUnmount() { if (this.editorRef.current) { const editorNode = this.editorRef.current.editor; if (editorNode) { editorNode.removeEventListener('compositionstart', this._handleCompositionStart); editorNode.removeEventListener('compositionupdate', this._handleCompositionUpdate); editorNode.removeEventListener('compositionend', this._handleCompositionEnd); editorNode.removeEventListener('input', this._handleInput); } } } render() { const { dataHook, multiline, rows, size, disabled, readOnly, placeholder, status, statusMessage, className, } = this.props; const singleLineProps = { handlePastedText: this._handlePastedText, handleReturn: () => 'handled', }; const finalStatus = getStatusFromContext(this.context, status); return (React.createElement("div", { "data-hook": dataHook, onMouseDown: this._onMouseDown, onMouseUp: this._onMouseUp, className: st(classes.root, { disabled, readOnly, size, status: finalStatus, singleLine: !multiline, }, className), style: { [vars.rows]: rows } }, React.createElement(EditorErrorBoundary, null, React.createElement(Editor, { ref: this.editorRef, spellCheck: this.props.spellCheck, editorState: this.state.editorState, onChange: this._onEditorChange, onFocus: this._onFocus, placeholder: placeholder, readOnly: disabled || readOnly, ...(readOnly && { tabIndex: 0 }), ...(!multiline && singleLineProps) })), (status || finalStatus === 'loading') && (React.createElement("span", { className: classes.indicatorWrapper }, React.createElement(StatusIndicator, { dataHook: dataHooks.indicator, status: finalStatus, message: statusMessage }))))); } } VariableInput.contextType = StatusContext; VariableInput.displayName = 'VariableInput'; VariableInput.defaultProps = { initialValue: '', multiline: true, rows: 1, spellCheck: false, size: sizeTypes.medium, variableParser: () => { }, variableTagPropsParser: () => ({}), variableTemplate: { prefix: '{{', suffix: '}}', }, }; export default VariableInput; //# sourceMappingURL=VariableInput.js.map