UNPKG

uxcore-mention

Version:

Mention anywhere with this component

343 lines (316 loc) 9.29 kB
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;