mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-14 08:35:29 +08:00
feat(composer-emojis): Add emojis to composer
Summary: Emojis can now be added in the composer window with colons and the emoji names (referenced by the same names used on Slack/GitHub/etc.). When using the correct syntax, if there are emojis that match the text typed, they appear in a dropdown floating toolbar. Selection works with either mouse clicks or arrow keys (plus `Enter`). Currently, the toolbar won't trigger if the colon is adjacent to a non-whitespace character. Test Plan: TODO: Will write tests soon! Reviewers: evan, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2505
This commit is contained in:
parent
521e2bdc61
commit
5c6b4adf9a
9 changed files with 365 additions and 1 deletions
12
internal_packages/composer-emojis/lib/emoji-actions.js
Normal file
12
internal_packages/composer-emojis/lib/emoji-actions.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/** @babel */
|
||||
import Reflux from 'reflux';
|
||||
|
||||
EmojiActions = Reflux.createActions([
|
||||
"selectEmoji"
|
||||
]);
|
||||
|
||||
for (key in EmojiActions) {
|
||||
EmojiActions[key].sync = true;
|
||||
}
|
||||
|
||||
export default EmojiActions;
|
50
internal_packages/composer-emojis/lib/emoji-picker.jsx
Normal file
50
internal_packages/composer-emojis/lib/emoji-picker.jsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import {React} from 'nylas-exports'
|
||||
import EmojiActions from './emoji-actions'
|
||||
const emoji = require('node-emoji');
|
||||
|
||||
class EmojiPicker extends React.Component {
|
||||
static displayName = "EmojiPicker"
|
||||
static propTypes = {
|
||||
emojiOptions: React.PropTypes.array,
|
||||
selectedEmoji: React.PropTypes.string
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
selectedButton = React.findDOMNode(this).querySelector(".emoji-option");
|
||||
if (selectedButton) {
|
||||
selectedButton.scrollIntoViewIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let emojis = [];
|
||||
emojiIndex = this.props.emojiOptions.indexOf(this.props.selectedEmoji);
|
||||
if (emojiIndex == -1) {
|
||||
emojiIndex = 0;
|
||||
}
|
||||
if (this.props.emojiOptions) {
|
||||
this.props.emojiOptions.forEach((emojiOption, i) => {
|
||||
const emojiChar = emoji.get(emojiOption);
|
||||
emojiClass = emojiIndex == i ? "btn btn-icon emoji-option" : "btn btn-icon";
|
||||
emojis.push(<button onMouseDown={() => this.onMouseDown(emojiChar)} className={emojiClass}>{emojiChar} :{emojiOption}:</button>);
|
||||
emojis.push(<br />);
|
||||
})
|
||||
}
|
||||
return(
|
||||
<div className="emoji-picker">
|
||||
{emojis}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onMouseDown(emojiChar) {
|
||||
EmojiActions.selectEmoji({emojiChar});
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiPicker;
|
|
@ -0,0 +1,214 @@
|
|||
import {DOMUtils, ContenteditableExtension} from 'nylas-exports'
|
||||
import EmojiActions from './emoji-actions'
|
||||
import EmojiPicker from './emoji-picker'
|
||||
const emoji = require('node-emoji');
|
||||
const emojis = Object.keys(emoji.emoji).sort();
|
||||
|
||||
class EmojisComposerExtension extends ContenteditableExtension {
|
||||
|
||||
static onContentChanged = ({editor, mutations}) => {
|
||||
sel = editor.currentSelection()
|
||||
let {emojiOptions, triggerWord} = EmojisComposerExtension._findEmojiOptions(sel);
|
||||
if (sel.anchorNode && sel.isCollapsed) {
|
||||
let {emojiOptions, triggerWord} = EmojisComposerExtension._findEmojiOptions(sel);
|
||||
if (emojiOptions.length > 0) {
|
||||
offset = sel.anchorOffset;
|
||||
if (!DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) {
|
||||
editor.select(sel.anchorNode,
|
||||
sel.anchorOffset - triggerWord.length,
|
||||
sel.focusNode,
|
||||
sel.focusOffset).wrapSelection("n1-emoji-autocomplete");
|
||||
editor.select(sel.anchorNode,
|
||||
offset,
|
||||
sel.anchorNode,
|
||||
offset);
|
||||
}
|
||||
}
|
||||
else {
|
||||
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.focusNode,
|
||||
sel.focusOffset + triggerWord.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
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.focusNode,
|
||||
sel.focusOffset + triggerWord.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static toolbarComponentConfig = ({toolbarState}) => {
|
||||
sel = toolbarState.selectionSnapshot;
|
||||
if (sel) {
|
||||
let {emojiOptions, triggerWord} = EmojisComposerExtension._findEmojiOptions(sel);
|
||||
if (emojiOptions.length > 0 && !toolbarState.dragging && !toolbarState.doubleDown) {
|
||||
locationRefNode = DOMUtils.closest(toolbarState.selectionSnapshot.anchorNode,
|
||||
"n1-emoji-autocomplete");
|
||||
emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete");
|
||||
selectedEmoji = emojiNameNode.getAttribute("selectedEmoji");
|
||||
return {
|
||||
component: EmojiPicker,
|
||||
props: {emojiOptions,
|
||||
selectedEmoji},
|
||||
locationRefNode: locationRefNode,
|
||||
width: EmojisComposerExtension._emojiPickerWidth(emojiOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static editingActions = () => {
|
||||
return [{
|
||||
action: EmojiActions.selectEmoji,
|
||||
callback: EmojisComposerExtension._onSelectEmoji
|
||||
}]
|
||||
}
|
||||
|
||||
static onKeyDown = ({editor, event}) => {
|
||||
sel = editor.currentSelection()
|
||||
let {emojiOptions, triggerWord} = EmojisComposerExtension._findEmojiOptions(sel);
|
||||
if (emojiOptions.length > 0) {
|
||||
if (event.key == "ArrowDown" || event.key == "ArrowRight" ||
|
||||
event.key == "ArrowUp" || event.key == "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
moveToNext = (event.key == "ArrowDown" || event.key == "ArrowRight")
|
||||
emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete");
|
||||
selectedEmoji = emojiNameNode.getAttribute("selectedEmoji");
|
||||
if (selectedEmoji) {
|
||||
emojiIndex = emojiOptions.indexOf(selectedEmoji);
|
||||
if (emojiIndex < emojiOptions.length - 1 && moveToNext) {
|
||||
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[emojiIndex + 1]);
|
||||
}
|
||||
else if (emojiIndex > 0 && !moveToNext) {
|
||||
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[emojiIndex - 1]);
|
||||
}
|
||||
else {
|
||||
index = moveToNext ? 0 : emojiOptions.length - 1;
|
||||
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[index]);
|
||||
}
|
||||
}
|
||||
else {
|
||||
index = moveToNext ? 1 : emojiOptions.length - 1;
|
||||
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[index]);
|
||||
}
|
||||
}
|
||||
else if (event.key == "Enter") {
|
||||
event.preventDefault();
|
||||
emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete");
|
||||
selectedEmoji = emojiNameNode.getAttribute("selectedEmoji");
|
||||
if (!selectedEmoji) selectedEmoji = emojiOptions[0];
|
||||
EmojisComposerExtension._onSelectEmoji({editor: editor,
|
||||
actionArg: {emojiChar: emoji.get(selectedEmoji)}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static _findEmojiOptions(sel) {
|
||||
if (sel.anchorNode &&
|
||||
sel.anchorNode.nodeValue &&
|
||||
sel.anchorNode.nodeValue.length > 0 &&
|
||||
sel.isCollapsed) {
|
||||
words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset).split(" ");
|
||||
lastWord = words[words.length - 1];
|
||||
if (words.length == 1 &&
|
||||
lastWord.indexOf(" ") == -1 &&
|
||||
lastWord.indexOf(":") == -1) {
|
||||
let {text, textNode} = 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).trim();
|
||||
if (lastWord.charAt(lastWord.length - 1) == ":") {
|
||||
word = word.substring(0, word.length - 1);
|
||||
}
|
||||
return {triggerWord: lastWord, emojiOptions: EmojisComposerExtension._findMatches(word)};
|
||||
}
|
||||
return {triggerWord: lastWord, emojiOptions: []};
|
||||
}
|
||||
return {triggerWord: "", emojiOptions: []};
|
||||
}
|
||||
|
||||
static _onSelectEmoji = ({editor, actionArg}) => {
|
||||
emojiChar = actionArg.emojiChar;
|
||||
if (!emojiChar) return null;
|
||||
sel = editor.currentSelection()
|
||||
if (sel.anchorNode &&
|
||||
sel.anchorNode.nodeValue &&
|
||||
sel.anchorNode.nodeValue.length > 0 &&
|
||||
sel.isCollapsed) {
|
||||
words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset).split(" ");
|
||||
lastWord = words[words.length - 1];
|
||||
if (words.length == 1 &&
|
||||
lastWord.indexOf(" ") == -1 &&
|
||||
lastWord.indexOf(":") == -1) {
|
||||
let {text, textNode} = EmojisComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset);
|
||||
lastWord = text;
|
||||
offset = textNode.nodeValue.lastIndexOf(":");
|
||||
editor.select(textNode,
|
||||
offset,
|
||||
sel.focusNode,
|
||||
sel.focusOffset);
|
||||
}
|
||||
else {
|
||||
editor.select(sel.anchorNode,
|
||||
sel.anchorOffset - lastWord.length,
|
||||
sel.focusNode,
|
||||
sel.focusOffset);
|
||||
}
|
||||
editor.insertText(emojiChar);
|
||||
}
|
||||
}
|
||||
|
||||
static _emojiPickerWidth(emojiOptions) {
|
||||
let max_length = 0;
|
||||
for (emojiOption of emojiOptions) {
|
||||
if (emojiOption.length > max_length) {
|
||||
max_length = emojiOption.length;
|
||||
}
|
||||
}
|
||||
WIDTH_PER_CHAR = 8;
|
||||
return (max_length + 10) * WIDTH_PER_CHAR;
|
||||
}
|
||||
|
||||
static _getTextUntilSpace(node, offset) {
|
||||
text = node.nodeValue.substring(0, offset);
|
||||
prevTextNode = DOMUtils.previousTextNode(node);
|
||||
if (!prevTextNode) return {text: text, textNode: node};
|
||||
while (prevTextNode) {
|
||||
if (prevTextNode.nodeValue.indexOf(" ") == -1 &&
|
||||
prevTextNode.nodeValue.indexOf(":") == -1) {
|
||||
text = prevTextNode.nodeValue + text;
|
||||
prevTextNode = DOMUtils.previousTextNode(prevTextNode);
|
||||
}
|
||||
else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
text = prevTextNode.nodeValue.trim() + text;
|
||||
return {text: text, textNode: prevTextNode};
|
||||
}
|
||||
|
||||
static _findMatches(word) {
|
||||
emojiOptions = []
|
||||
for (const curEmoji of emojis) {
|
||||
if (word == curEmoji.substring(0, word.length)) {
|
||||
emojiOptions.push(curEmoji);
|
||||
}
|
||||
}
|
||||
return emojiOptions;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EmojisComposerExtension;
|
11
internal_packages/composer-emojis/lib/main.js
Normal file
11
internal_packages/composer-emojis/lib/main.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/** @babel */
|
||||
import {ExtensionRegistry} from 'nylas-exports';
|
||||
import EmojisComposerExtension from './emojis-composer-extension'
|
||||
|
||||
export function activate() {
|
||||
ExtensionRegistry.Composer.register(EmojisComposerExtension);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ExtensionRegistry.Composer.unregister(EmojisComposerExtension);
|
||||
}
|
21
internal_packages/composer-emojis/package.json
Normal file
21
internal_packages/composer-emojis/package.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "composer-emojis",
|
||||
"main": "./lib/main",
|
||||
"version": "0.1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": ""
|
||||
},
|
||||
"engines": {
|
||||
"nylas": ">=0.3.43-b95f1f7"
|
||||
},
|
||||
"description": "A composer extension for adding emojis to your emails.",
|
||||
"dependencies": {
|
||||
"node-emoji": "^1.0.4"
|
||||
},
|
||||
"windowTypes": {
|
||||
"default": true,
|
||||
"composer": true
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
@import "ui-variables";
|
||||
.emoji-picker {
|
||||
max-height: 155px !important;
|
||||
padding: 10px 10px 10px 10px !important;
|
||||
overflow: auto;
|
||||
.btn.btn-icon.emoji-option {
|
||||
background-color: @btn-emphasis-bg-color;
|
||||
color: #FFFFFF;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.btn.btn-icon:hover {
|
||||
background-color: @btn-emphasis-bg-color;
|
||||
color: #FFFFFF;
|
||||
border-radius: 15px;
|
||||
}
|
||||
}
|
||||
|
|
@ -136,6 +136,7 @@ class Contenteditable extends React.Component
|
|||
componentDidMount: =>
|
||||
@setInnerState editableNode: @_editableNode()
|
||||
@_setupNonMutationListeners()
|
||||
@_setupEditingActionListeners()
|
||||
@_mutationObserver.observe(@_editableNode(), @_mutationConfig())
|
||||
|
||||
# When we have a composition event in progress, we should not update
|
||||
|
@ -161,6 +162,7 @@ class Contenteditable extends React.Component
|
|||
componentWillUnmount: =>
|
||||
@_mutationObserver.disconnect()
|
||||
@_teardownNonMutationListeners()
|
||||
@_teardownEditingActionListeners()
|
||||
@_teardownServices()
|
||||
|
||||
setInnerState: (innerState={}) =>
|
||||
|
@ -262,6 +264,25 @@ class Contenteditable extends React.Component
|
|||
document.removeEventListener("selectionchange", @_saveSelection)
|
||||
@_editableNode().removeEventListener('contextmenu', @_onShowContextMenu)
|
||||
|
||||
_setupEditingActionListeners: =>
|
||||
if @editingActionUnsubscribers?.length > 0
|
||||
editingActionUnsubscriber() for editingActionUnsubscriber in @editingActionUnsubscribers
|
||||
@editingActionUnsubscribers = []
|
||||
|
||||
@_extensions().forEach (ext) =>
|
||||
try
|
||||
editingActions = ext.editingActions?() ? []
|
||||
editingActions.forEach ({action, callback}) =>
|
||||
@editingActionUnsubscribers.push(action.listen((actionArg) =>
|
||||
@atomicEdit(callback, {actionArg})
|
||||
))
|
||||
catch error
|
||||
NylasEnv.emitError(error)
|
||||
|
||||
_teardownEditingActionListeners: =>
|
||||
for editingActionUnsubscriber in @editingActionUnsubscribers
|
||||
editingActionUnsubscriber()
|
||||
|
||||
# https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
|
||||
_mutationConfig: ->
|
||||
subtree: true
|
||||
|
|
|
@ -128,7 +128,7 @@ class FloatingToolbar extends React.Component
|
|||
NylasEnv.emitError(error)
|
||||
|
||||
if toolbarComponent and not toolbarLocationRef
|
||||
throw new Error("You must provider a locationRefNode for #{toolbarComponent.displayName}")
|
||||
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}
|
||||
|
||||
|
@ -137,6 +137,9 @@ class FloatingToolbar extends React.Component
|
|||
_calculatePositionState: (props, {toolbarLocationRef, toolbarWidth}) =>
|
||||
editableNode = props.editableNode
|
||||
|
||||
if not _.isFunction(toolbarLocationRef.getBoundingClientRect)
|
||||
throw new Error("Your locationRefNode must implement getBoundingClientRect. Be aware that Text nodes do not implement this, but Element nodes do. Find the nearest Element relative.")
|
||||
|
||||
referenceRect = toolbarLocationRef.getBoundingClientRect()
|
||||
|
||||
if not editableNode or not referenceRect or DOMUtils.isEmptyBoundingRect(referenceRect)
|
||||
|
|
|
@ -186,6 +186,13 @@ DOMUtils =
|
|||
return unless selection?.isCollapsed
|
||||
return DOMUtils.closest(selection.anchorNode, selector)
|
||||
|
||||
closestElement: (node) ->
|
||||
if node instanceof HTMLElement
|
||||
return node
|
||||
else if node?.parentNode
|
||||
return DOMUtils.closestElement(node.parentNode)
|
||||
else return null
|
||||
|
||||
isInList: ->
|
||||
li = DOMUtils.closestAtCursor("li")
|
||||
list = DOMUtils.closestAtCursor("ul, ol")
|
||||
|
@ -671,4 +678,12 @@ DOMUtils =
|
|||
else
|
||||
return false
|
||||
|
||||
previousTextNode: (node) ->
|
||||
curNode = node
|
||||
while curNode.parentNode
|
||||
if curNode.previousSibling
|
||||
return this.findLastTextNode(curNode.previousSibling)
|
||||
curNode = curNode.parentNode
|
||||
return null
|
||||
|
||||
module.exports = DOMUtils
|
||||
|
|
Loading…
Add table
Reference in a new issue