Skip to content

Instantly share code, notes, and snippets.

@dcousineau
Created July 21, 2015 18:57
Show Gist options
  • Save dcousineau/78c6759caa80ca204a10 to your computer and use it in GitHub Desktop.
Save dcousineau/78c6759caa80ca204a10 to your computer and use it in GitHub Desktop.
/**
* 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;
}
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