uxcore-mention
Version:
Mention anywhere with this component
343 lines (316 loc) • 9.29 kB
JSX
import React from 'react';
import PropTypes from 'prop-types';
import { polyfill } from 'react-lifecycles-compat';
import BaseEditor from './baseEditor';
import { parseStrByDelimiter } from '../utils/util';
const rangy = global.rangy;
// webkit browsers support 'plaintext-only'
const contentEditableValue = (() => {
// only this format can solve `document is not defined` in nodejs
if (typeof document !== 'undefined') {
const div = document.createElement('div');
div.setAttribute('contenteditable', 'PLAINTEXT-ONLY');
return div.contentEditable === 'plaintext-only' ? 'plaintext-only' : true;
}
return true;
})();
class ContentEditableEditor extends BaseEditor {
static displayName = 'ContentEditableEditor';
static propTypes = {
/**
* @i18n {zh-CN} class前缀
* @i18n {en-US} class prefix
*/
prefixCls: PropTypes.string,
/**
* @i18n {zh-CN} 编辑区域宽度
* @i18n {en-US} editor's width
*/
width: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
]),
/**
* @i18n {zh-CN} 编辑区域高度
* @i18n {en-US} editor's height
*/
height: PropTypes.number,
/**
* @i18n {zh-CN} placeholder
* @i18n {en-US} placeholder
*/
placeholder: PropTypes.string,
/**
* @i18n {zh-CN} 自定义插入的mention内容
* @i18n {en-US} customize the insert content with this function | function
*/
mentionFormatter: PropTypes.func,
/**
* @i18n {zh-CN} 发生变化后的触发
* @i18n {en-US} trigger when editor content change
*/
onChange: PropTypes.func,
/**
* @i18n {zh-CN} 添加mention后触发
* @i18n {en-US} Callback invoked when a mention has been added
*/
onAdd: PropTypes.func,
/**
* @i18n {zh-CN} 默认内容
* @i18n {en-US} default value
*/
defaultValue: PropTypes.string,
/**
* @i18n {zh-CN} 内容
* @i18n {en-US} value
*/
value: PropTypes.string,
/**
* @i18n {zh-CN} 只读
* @i18n {en-US} read only
*/
readOnly: PropTypes.bool,
/**
* @i18n {zh-CN} 触发字符
* @i18n {en-US} Defines the char sequence upon which to trigger querying the data source
*/
delimiter: PropTypes.string,
/**
* @i18n {zh-CN} 最大长度
* @i18n {en-US} max length of content
*/
maxLength: PropTypes.number,
};
static defaultProps = {
prefixCls: '',
width: 200,
height: 100,
placeholder: '',
mentionFormatter: data => `@${data.text}`,
onChange: () => {},
onAdd: () => {},
defaultValue: '',
readOnly: false,
delimiter: '@',
maxLength: -1,
};
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.value !== prevState.prveValue) {
return {
value: nextProps.value,
prveValue: nextProps.value,
};
}
return null;
}
constructor(props) {
super(props);
const val = props.value || props.defaultValue;
this.state = {
focus: false,
value: val,
readOnly: this.props.readOnly,
prveValue: val,
};
}
componentDidMount() {
this.STORE = {};
if (this.state.value) {
this.editor.innerHTML = this.state.value;
}
const MutationObserver =
window.MutationObserver || window.WebkitMutationObserver || window.MozMutationObserver;
if (MutationObserver) {
this.observer = new MutationObserver(this.onMutation.bind(this));
this.observer.observe(this.editor, {
characterData: true,
childList: true,
subtree: true,
});
}
}
componentDidUpdate(prevProps) {
if (prevProps.value !== this.props.value) {
this.editor.innerHTML = this.state.value;
}
}
componentWillUnmount() {
if (this.observer) {
this.observer.disconnect();
}
}
handleEnterPress(e) {
e.preventDefault();
const editor = this.editor;
const sel = rangy.getSelection();
const range = sel.getRangeAt(0);
// make sure the last element of the editor is br
// refer to: http://stackoverflow.com/questions/6023307/dealing-with-line-breaks-on-contenteditable-div
if (!editor.lastChild ||
editor.lastChild.nodeName.toLowerCase() !== 'br') {
editor.appendChild(document.createElement('br'));
}
const nodeBr = document.createElement('br');
range.deleteContents();
range.insertNode(nodeBr);
range.setStartAfter(nodeBr);
sel.setSingleRange(range);
}
handleDefaultKeyup() {
const { delimiter } = this.props;
const sel = rangy.getSelection();
const range = sel.getRangeAt(0);
if (range.commonAncestorContainer.nodeType === 3) {
range.setStart(range.commonAncestorContainer, 0);
const originStr = range.toString();
const str = parseStrByDelimiter(originStr, delimiter);
// send str to matcher
this.props.matcher(str);
if (str !== false) {
// set range's start position before delimiter
range.setStart(range.commonAncestorContainer, originStr.length - str.length - 1);
const pos = range.getEndClientPos();
// FIXME: ie8 will return error position in some case
this.props.setCursorPos(pos);
// save range position
this.STORE.bookmark = range.getBookmark(range.commonAncestorContainer);
}
}
}
onInput() {
if (!this.observer) {
this.emitChange();
}
}
onMutation(mutations) {
this.emitChange(mutations);
}
onBlur() {
this.emitChange();
this.setState({
focus: false,
});
}
onFocus() {
this.setState({
focus: true,
});
this.props.onFocus(this);
}
insert(mentionContent) {
const editor = this.editor;
const sel = rangy.getSelection();
if (this.STORE.bookmark) {
const range = sel.getRangeAt(0);
range.moveToBookmark(this.STORE.bookmark);
if (Array.isArray(mentionContent)) {
mentionContent.map(item => this.addSelectPerson(item, range));
} else {
this.addSelectPerson(mentionContent, range);
}
setTimeout(() => editor.focus(), 0);
}
}
addSelectPerson(item, range) {
const prefixCls = `${this.props.prefixCls}-node`;
const mentionNode = document.createElement('input');
mentionNode.setAttribute('type', 'button');
mentionNode.setAttribute('tabindex', '-1');
mentionNode.className = prefixCls;
mentionNode.value = item;
// delete origin content in range
range.deleteContents();
range.insertNode(mentionNode);
range.collapseAfter(mentionNode);
range.select();
}
extractContent() {
// console.time('extractContent');
const editor = this.editor;
const nodes = editor.childNodes;
let content = '';
for (let i = 0, len = nodes.length; i < len; i += 1) {
const node = nodes[i];
if (node.nodeType === 1) {
const tagName = node.tagName.toLowerCase();
if (tagName === 'input') {
content += ` ${node.value} `;
} else if (tagName === 'br') {
content += '\n';
}
} else if (node.nodeType === 3) {
content += node.textContent || node.nodeValue;
}
}
// console.timeEnd('extractContent');
return content;
}
emitChange(e) {
if (this.emitFromMaxLength) {
this.emitFromMaxLength = false;
return;
}
if (!this.observer) {
const editor = this.editor;
const lastHtml = this.lastHtml;
const currentHtml = editor.innerHTML;
if (lastHtml === currentHtml) {
// no change made
return;
}
this.lastHtml = currentHtml;
}
const content = this.extractContent();
const value = this.props.maxLength > 0
? content.substring(0, this.props.maxLength)
: content;
this.setState({
value,
});
if (this.props.maxLength > 0 && content.length > this.props.maxLength) {
this.editor.innerHTML = value;
this.emitFromMaxLength = true;
// 焦点问题太难hack,所以通过移除焦点来禁止用户输入
this.editor.blur();
}
this.props.onChange(e, value);
}
render() {
const { placeholder } = this.props;
const { readOnly } = this.state;
const style = {
width: this.props.width,
height: this.props.height,
};
return (
<div className={this.props.prefixCls}>
<div
className={`${this.props.prefixCls}-editor`}
ref={el => (this.editor = el)}
onKeyUp={this.onKeyup.bind(this)}
onKeyDown={this.onKeydown.bind(this)}
contentEditable={readOnly ? false : contentEditableValue}
onInput={this.onInput.bind(this)}
onBlur={this.onBlur.bind(this)}
onFocus={this.onFocus.bind(this)}
style={style}
/>
{
!this.state.focus && !this.state.value ?
<div
className={`${this.props.prefixCls}-placeholder`}
onClick={() => {
this.editor.focus();
this.onFocus();
}}
>
{placeholder}
</div>
: ''
}
</div>
);
}
}
polyfill(ContentEditableEditor);
export default ContentEditableEditor;