Created
July 21, 2015 18:57
-
-
Save dcousineau/78c6759caa80ca204a10 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Forked from https://github.com/component/textarea-caret-position | |
*/ | |
const CLONE_CSS = [ | |
'direction', // RTL support | |
'boxSizing', | |
'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does | |
'height', | |
'overflowX', | |
'overflowY', // copy the scrollbar for IE | |
'borderTopWidth', | |
'borderRightWidth', | |
'borderBottomWidth', | |
'borderLeftWidth', | |
'borderStyle', | |
'paddingTop', | |
'paddingRight', | |
'paddingBottom', | |
'paddingLeft', | |
// https://developer.mozilla.org/en-US/docs/Web/CSS/font | |
'fontStyle', | |
'fontVariant', | |
'fontWeight', | |
'fontStretch', | |
'fontSize', | |
'fontSizeAdjust', | |
'lineHeight', | |
'fontFamily', | |
'textAlign', | |
'textTransform', | |
'textIndent', | |
'textDecoration', // might not make a difference, but better be safe | |
'letterSpacing', | |
'wordSpacing', | |
'tabSize', | |
'MozTabSize' | |
]; | |
const isFirefox = window.mozInnerScreenX != null; | |
export default function getCaretCoordinates(element, position) { | |
// mirrored div | |
let div = document.createElement('div'); | |
div.id = 'input-textarea-caret-position-mirror-div'; | |
document.body.appendChild(div); | |
let style = div.style; | |
const computed = window.getComputedStyle? getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9 | |
// default textarea styles | |
style.whiteSpace = 'pre-wrap'; | |
if (element.nodeName !== 'INPUT') | |
style.wordWrap = 'break-word'; // only for textarea-s | |
// position off-screen | |
style.position = 'absolute'; // required to return coordinates properly | |
style.visibility = 'hidden'; // not 'display: none' because we want rendering | |
// transfer the element's properties to the div | |
CLONE_CSS.forEach(prop => style[prop] = computed[prop]); | |
if (isFirefox) { | |
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 | |
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll'; | |
} else { | |
style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' | |
} | |
div.textContent = element.value.substring(0, position); | |
// the second special handling for input type="text" vs textarea: spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 | |
if (element.nodeName === 'INPUT') | |
div.textContent = div.textContent.replace(/\s/g, "\u00a0"); | |
let span = document.createElement('span'); | |
// Wrapping must be replicated *exactly*, including when a long word gets | |
// onto the next line, with whitespace at the end of the line before (#7). | |
// The *only* reliable way to do that is to copy the *entire* rest of the | |
// textarea's content into the <span> created at the caret position. | |
// for inputs, just '.' would be enough, but why bother? | |
span.textContent = element.value.substring(position) || '.'; // || because a completely empty faux span doesn't render at all | |
div.appendChild(span); | |
let coordinates = { | |
top: span.offsetTop + parseInt(computed['borderTopWidth']), | |
left: span.offsetLeft + parseInt(computed['borderLeftWidth']) | |
}; | |
document.body.removeChild(div); | |
return coordinates; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import throttle from 'lodash/function/throttle.js'; | |
import extend from 'lodash/object/extend.js'; | |
import getCaretPosition from 'caret.js'; | |
const TEST_DATA = [ | |
{ value: "SomethingNoSpaces", label: "Something No Spaces" } | |
]; | |
export default class MentionArea extends React.Component { | |
static propTypes = { | |
tagChar: React.PropTypes.string, | |
onChange: React.PropTypes.func | |
}; | |
static defaultProps = { | |
tagChar: "@", | |
onChange: function() {} | |
}; | |
state = { | |
showingResults: false, | |
results: [] | |
}; | |
onChange = throttle(value => { | |
let input = React.findDOMNode(this.refs.textarea); | |
//Calculate first word boundary after the cursor (so we can autocomplete in the middle of a tag) | |
let wordEnd = value.indexOf(" ", input.selectionStart - 1); | |
//If we don't find one, use the end of the string | |
if (wordEnd === -1) wordEnd = value.length; | |
//Slide window over to the first word boundary after the cursor | |
let valueWindow = value.slice(0, wordEnd); | |
//Grab the last word of the window | |
let lastWord = valueWindow.split(" ").pop(); | |
if (lastWord.charAt(0) == this.props.tagChar) { | |
let position = valueWindow.lastIndexOf(lastWord); | |
//If the first char of the last word in our window is our tag char we know to start a search | |
//So, lets build our query: slice off the tag char | |
let query = lastWord.slice(1); | |
if (!this.state.showingResults) { | |
//If we weren't already showing results, mark them as to-be-shown and execute the search | |
this.setState({showingResults: true}, () => this.doSearch(query, position, lastWord.length)); | |
} else { | |
//Otherwise just start executing the search | |
this.doSearch(query, position, lastWord.length); | |
} | |
} else if (this.state.showingResults) { | |
//Otherwise if we WERE searching turn off and hide the search | |
this.setState({showingResults: false, results: []}); | |
} | |
}, 250); | |
doSearch(query, start, length) { | |
console.debug("Performing search", query); | |
let regex = new RegExp(`^${query}`, 'i'); | |
let results = TEST_DATA.filter(item => item.value.match(regex)).map(item => extend({}, item, {start, length})); | |
this.setState(() => { | |
return {results}; | |
}); | |
} | |
useResult({value, label, start, length}) { | |
let input = React.findDOMNode(this.refs.textarea); | |
let [left, right] = [input.value.slice(0, start), input.value.slice(start+length, input.value.length)]; | |
let replacement = `${this.props.tagChar}${value} `; | |
let newValue = `${left}${replacement}${right}`; | |
let caretPos = start + replacement.length; | |
//Move caret to the end of the replacment we just made | |
input.focus(); | |
input.setSelectionRange(caretPos, caretPos); | |
input.value = newValue; | |
//React listens for "input" events, not "change" events | |
let event = new UIEvent("input", { | |
view: window, bubbles: true, cancelable: true | |
}); | |
input.dispatchEvent(event); | |
this.setState({showingResults: false, results: []}); | |
} | |
getResultsPosition() { | |
let input = React.findDOMNode(this.refs.textarea); | |
let {top, left} = getCaretPosition(input, input.selectionStart); | |
return { | |
position: 'absolute', | |
top, | |
left, | |
width: 100, | |
height: 100, | |
background: "#fff" | |
}; | |
} | |
render() { | |
let suggestions = null; | |
if (this.state.showingResults) { | |
let results = this.state.results.map(result => <li key={`suggestion-${result.value}`} onClick={() => this.useResult(result)}>{result.label}</li>); | |
suggestions = (<ul className="results" style={this.getResultsPosition()}>{results}</ul>); | |
} | |
return ( | |
<div className="mentions-autocomplete-wrapper" style={{position: 'relative'}}> | |
<textarea {...this.props} ref="textarea" onChange={(e, ...args) => {this.onChange(e.target.value); this.props.onChange(e);}} /> | |
{suggestions} | |
</div> | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment