@wix/design-system
Version:
@wix/design-system
300 lines • 13.7 kB
JavaScript
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