fix(composer-emojis): Add spec, allow adjacent emojis, fix toolbar positioning bug

Summary:
Adds tests to the emoji picker.

The emoji picker should also now be able to add emojis consecutively (without spaces).

Finally, the toolbar positioning bug (emoji picker appearing in front of typed text, the toolbar manager appearing in the upper left corner when empty lines are selected) should be fixed so that the toolbar appears directly above/below the selection area.

Test Plan: Tests included in diff.

Reviewers: evan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2551
This commit is contained in:
Jackie Luo 2016-02-12 11:41:11 -08:00
parent 280015b5c0
commit ecac59f1c3
7 changed files with 182 additions and 39 deletions

View file

@ -14,7 +14,7 @@ class EmojisComposerExtension extends ContenteditableExtension {
const offset = sel.anchorOffset;
if (!DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) {
editor.select(sel.anchorNode,
sel.anchorOffset - triggerWord.length,
sel.anchorOffset - triggerWord.length - 1,
sel.focusNode,
sel.focusOffset).wrapSelection("n1-emoji-autocomplete");
editor.select(sel.anchorNode,
@ -26,9 +26,9 @@ class EmojisComposerExtension extends ContenteditableExtension {
if (DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) {
editor.unwrapNodeAndSelectAll(DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete"));
editor.select(sel.anchorNode,
sel.anchorOffset + triggerWord.length,
sel.anchorOffset + triggerWord.length + 1,
sel.focusNode,
sel.focusOffset + triggerWord.length);
sel.focusOffset + triggerWord.length + 1);
}
}
} else {
@ -49,15 +49,15 @@ class EmojisComposerExtension extends ContenteditableExtension {
if (emojiOptions.length > 0 && !toolbarState.dragging && !toolbarState.doubleDown) {
const locationRefNode = DOMUtils.closest(sel.anchorNode,
"n1-emoji-autocomplete");
const emojiNameNode = DOMUtils.closest(sel.anchorNode,
"n1-emoji-autocomplete");
const selectedEmoji = emojiNameNode.getAttribute("selectedEmoji");
if (!locationRefNode) return null;
const selectedEmoji = locationRefNode.getAttribute("selectedEmoji");
return {
component: EmojiPicker,
props: {emojiOptions,
selectedEmoji},
locationRefNode: locationRefNode,
width: EmojisComposerExtension._emojiPickerWidth(emojiOptions),
height: EmojisComposerExtension._emojiPickerHeight(emojiOptions),
}
}
}
@ -111,22 +111,22 @@ class EmojisComposerExtension extends ContenteditableExtension {
sel.anchorNode.nodeValue &&
sel.anchorNode.nodeValue.length > 0 &&
sel.isCollapsed) {
const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset).split(" ");
let lastWord = words[words.length - 1].trim();
if (words.length === 1 &&
lastWord.indexOf(" ") === -1 &&
lastWord.indexOf(":") === -1) {
const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset);
let index = words.lastIndexOf(":");
let lastWord = "";
if (index !== -1 && words.lastIndexOf(" ") < index) {
lastWord = words.substring(index + 1, sel.anchorOffset);
} else {
const {text} = EmojisComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset);
lastWord = text;
}
if (lastWord.length > 1 &&
lastWord.charAt(0) === ":" &
lastWord.charAt(lastWord.length - 1) !== " ") {
let word = lastWord.substring(1);
if (lastWord.charAt(lastWord.length - 1) === ":") {
word = word.substring(0, word.length - 1);
index = text.lastIndexOf(":");
if (index !== -1 && text.lastIndexOf(" ") < index) {
lastWord = text.substring(index + 1);
} else {
return {triggerWord: "", emojiOptions: []};
}
return {triggerWord: lastWord, emojiOptions: EmojisComposerExtension._findMatches(word)};
}
if (lastWord.length > 0) {
return {triggerWord: lastWord, emojiOptions: EmojisComposerExtension._findMatches(lastWord)};
}
return {triggerWord: lastWord, emojiOptions: []};
}
@ -140,22 +140,22 @@ class EmojisComposerExtension extends ContenteditableExtension {
if (sel.anchorNode &&
sel.anchorNode.nodeValue &&
sel.anchorNode.nodeValue.length > 0 &&
sel.isCollapsed) {
const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset).split(" ");
let lastWord = words[words.length - 1].trim();
if (words.length === 1 &&
lastWord.indexOf(" ") === -1 &&
lastWord.indexOf(":") === -1) {
const {text, textNode} = EmojisComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset);
lastWord = text;
const offset = textNode.nodeValue.lastIndexOf(":");
editor.select(textNode,
offset,
sel.isCollapsed) {
const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset);
let index = words.lastIndexOf(":");
let lastWord = words.substring(index + 1, sel.anchorOffset);
if (index !== -1 && words.lastIndexOf(" ") < index) {
editor.select(sel.anchorNode,
sel.anchorOffset - lastWord.length - 1,
sel.focusNode,
sel.focusOffset);
} else {
editor.select(sel.anchorNode,
sel.anchorOffset - lastWord.length,
const {text, textNode} = EmojisComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset);
index = text.lastIndexOf(":");
lastWord = text.substring(index + 1);
const offset = textNode.nodeValue.lastIndexOf(":");
editor.select(textNode,
offset,
sel.focusNode,
sel.focusOffset);
}
@ -174,6 +174,14 @@ class EmojisComposerExtension extends ContenteditableExtension {
return (maxLength + 10) * WIDTH_PER_CHAR;
}
static _emojiPickerHeight(emojiOptions) {
const HEIGHT_PER_EMOJI = 28;
if (emojiOptions.length < 5) {
return emojiOptions.length * HEIGHT_PER_EMOJI + 20;
}
return 5 * HEIGHT_PER_EMOJI + 20;
}
static _getTextUntilSpace(node, offset) {
let text = node.nodeValue.substring(0, offset);
let prevTextNode = DOMUtils.previousTextNode(node);

View file

@ -1,6 +1,6 @@
/** @babel */
import {ExtensionRegistry} from 'nylas-exports';
import EmojisComposerExtension from './emojis-composer-extension'
import EmojisComposerExtension from './emojis-composer-extension';
export function activate() {
ExtensionRegistry.Composer.register(EmojisComposerExtension);
@ -8,4 +8,4 @@ export function activate() {
export function deactivate() {
ExtensionRegistry.Composer.unregister(EmojisComposerExtension);
}
}

View file

@ -0,0 +1,116 @@
import React, {addons} from 'react/addons';
import {renderIntoDocument} from '../../../spec/nylas-test-utils';
import Contenteditable from '../../../src/components/contenteditable/contenteditable';
import EmojisComposerExtension from '../lib/emojis-composer-extension';
const ReactTestUtils = addons.TestUtils;
describe('EmojisComposerExtension', ()=> {
beforeEach(()=> {
spyOn(EmojisComposerExtension, 'onContentChanged').andCallThrough()
spyOn(EmojisComposerExtension, '_onSelectEmoji').andCallThrough()
const html = 'Testing!'
const onChange = jasmine.createSpy('onChange')
this.component = renderIntoDocument(
<Contenteditable html={html} onChange={onChange} extensions={[EmojisComposerExtension]}/>
)
this.editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(this.component, 'contentEditable'));
})
describe('when emoji trigger is typed', ()=> {
beforeEach(()=> {
this._performEdit = (newHTML) => {
this.editableNode.innerHTML = newHTML;
const sel = document.getSelection()
const textNode = this.editableNode.childNodes[0];
sel.setBaseAndExtent(textNode, textNode.nodeValue.length, textNode, textNode.nodeValue.length);
}
})
it('should show the emoji picker', ()=> {
runs(()=> {
this._performEdit('Testing! :h');
});
waitsFor(()=> {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0
});
})
it('should be focused on the first emoji in the list', ()=> {
runs(()=> {
this._performEdit('Testing! :h');
});
waitsFor(()=> {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length > 0
});
runs(()=> {
expect(React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')).textContent === "💇 :haircut:").toBe(true);
});
})
it('should insert an emoji on enter', ()=> {
runs(()=> {
this._performEdit('Testing! :h');
});
waitsFor(()=> {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0
});
runs(()=> {
ReactTestUtils.Simulate.keyDown(this.editableNode, {key: "Enter", keyCode: 13, which: 13});
});
waitsFor(()=> {
return EmojisComposerExtension._onSelectEmoji.calls.length > 0
})
runs(()=> {
expect(this.editableNode.textContent === "Testing! 💇").toBe(true);
});
})
it('should insert an emoji on click', ()=> {
runs(()=> {
this._performEdit('Testing! :h');
});
waitsFor(()=> {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0
});
runs(()=> {
const button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option'))
ReactTestUtils.Simulate.mouseDown(button);
expect(EmojisComposerExtension._onSelectEmoji).toHaveBeenCalled()
});
waitsFor(()=> {
return EmojisComposerExtension._onSelectEmoji.calls.length > 0
})
runs(()=> {
expect(this.editableNode.textContent).toEqual("Testing! 💇");
});
})
it('should move to the next emoji on arrow down', ()=> {
runs(()=> {
this._performEdit('Testing! :h');
});
waitsFor(()=> {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length > 0
});
runs(()=> {
ReactTestUtils.Simulate.keyDown(this.editableNode, {key: "ArrowDown", keyCode: 40, which: 40});
});
waitsFor(()=> {
return EmojisComposerExtension.onContentChanged.calls.length > 1
});
runs(()=> {
expect(React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')).textContent).toEqual("🍔 :hamburger:");
});
})
it('should be able to insert two emojis next to each other', ()=> {
runs(()=> {
this._performEdit('Testing! 🍔 :h');
});
waitsFor(()=> {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0
});
})
})
})

View file

@ -53,6 +53,7 @@ class FloatingToolbar extends React.Component
toolbarPos: "above"
editAreaWidth: 9999 # This will get set on first selection
toolbarWidth: 0
toolbarHeight: 0
toolbarComponent: null
toolbarLocationRef: null
toolbarComponentProps: {}
@ -113,6 +114,7 @@ class FloatingToolbar extends React.Component
_getToolbarComponentData: (props) ->
toolbarComponent = null
toolbarWidth = 0
toolbarHeight = 0
toolbarLocationRef = null
toolbarComponentProps = {}
@ -124,17 +126,18 @@ class FloatingToolbar extends React.Component
toolbarComponentProps = params.props ? {}
toolbarLocationRef = params.locationRefNode
toolbarWidth = params.width
toolbarHeight = params.height
catch error
NylasEnv.reportError(error)
if toolbarComponent and not toolbarLocationRef
throw new Error("You must provide a locationRefNode for #{toolbarComponent.displayName}. It must be either a DOM Element or a Range.")
return {toolbarComponent, toolbarComponentProps, toolbarLocationRef, toolbarWidth}
return {toolbarComponent, toolbarComponentProps, toolbarLocationRef, toolbarWidth, toolbarHeight}
@CONTENT_PADDING: 15
_calculatePositionState: (props, {toolbarLocationRef, toolbarWidth}) =>
_calculatePositionState: (props, {toolbarLocationRef, toolbarWidth, toolbarHeight}) =>
editableNode = props.editableNode
if not _.isFunction(toolbarLocationRef.getBoundingClientRect)
@ -154,7 +157,7 @@ class FloatingToolbar extends React.Component
calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2
calcLeft = Math.min(Math.max(calcLeft, FloatingToolbar.CONTENT_PADDING+BORDER_RADIUS_PADDING), editArea.width - BORDER_RADIUS_PADDING)
calcTop = referenceRect.top - editArea.top - 48
calcTop = referenceRect.top - editArea.top - toolbarHeight - 14
toolbarPos = "above"
if calcTop < TOP_PADDING
calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING + 4
@ -166,6 +169,7 @@ class FloatingToolbar extends React.Component
toolbarTop: calcTop
toolbarLeft: calcLeft
toolbarWidth: Math.min(maxWidth, toolbarWidth)
toolbarHeight: toolbarHeight
editAreaWidth: editArea.width
toolbarPos: toolbarPos
}
@ -182,6 +186,7 @@ class FloatingToolbar extends React.Component
left: @_toolbarLeft()
top: @state.toolbarTop
width: @state.toolbarWidth
height: @state.toolbarHeight
return styles
_toolbarLeft: =>

View file

@ -57,6 +57,7 @@ class LinkManager extends ContenteditableExtension
focusOnMount: @_shouldFocusOnMount(toolbarState)
locationRefNode: linkToModify
width: @_linkWidth(linkToModify)
height: 34
}
@_shouldFocusOnMount: (toolbarState) ->

View file

@ -18,13 +18,23 @@ class ToolbarButtonManager extends ContenteditableExtension
return null unless locationRef
buttonConfigs = @_toolbarButtonConfigs(toolbarState)
range = DOMUtils.getRangeInScope(toolbarState.editableNode)
if !range or !range.startContainer
return null
if range.startContainer.nodeType is Node.ELEMENT_NODE
locationRefNode = range.startContainer.childNodes[range.startOffset]
if !locationRefNode
locationRefNode = range
else
locationRefNode = range
return {
component: ToolbarButtons
props:
buttonConfigs: buttonConfigs
locationRefNode: locationRef
locationRefNode: locationRefNode
width: buttonConfigs.length * 28.5
height: 34
}
@_toolbarButtonConfigs: (toolbarState) ->

View file

@ -214,6 +214,9 @@ class ContenteditableExtension
- width: The width of your component. This is necessary because when
your component is displayed in the {FloatingToolbar}, the position is
pre-computed based on the absolute width of the item.
- height: The height of your component. This is necessary for the same
reason listed above; the position of the toolbar will be determined
by the absolute height given.
###
@toolbarComponentConfig: ({toolbarState}) ->