mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 05:06:53 +08:00
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:
parent
280015b5c0
commit
ecac59f1c3
7 changed files with 182 additions and 39 deletions
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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: =>
|
||||
|
|
|
@ -57,6 +57,7 @@ class LinkManager extends ContenteditableExtension
|
|||
focusOnMount: @_shouldFocusOnMount(toolbarState)
|
||||
locationRefNode: linkToModify
|
||||
width: @_linkWidth(linkToModify)
|
||||
height: 34
|
||||
}
|
||||
|
||||
@_shouldFocusOnMount: (toolbarState) ->
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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}) ->
|
||||
|
||||
|
|
Loading…
Reference in a new issue