2021-01-14 18:07:05 +00:00
// Copyright 2020-2021 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2020-10-21 16:53:32 +00:00
import Quill from 'quill' ;
import Delta from 'quill-delta' ;
import React from 'react' ;
2022-08-18 15:02:13 +00:00
import _ , { isNumber } from 'lodash' ;
2020-10-21 16:53:32 +00:00
import { Popper } from 'react-popper' ;
import classNames from 'classnames' ;
import { createPortal } from 'react-dom' ;
2022-08-18 15:02:13 +00:00
import type { VirtualElement } from '@popperjs/core' ;
2021-10-26 19:15:33 +00:00
import type { EmojiData } from '../../components/emoji/lib' ;
2020-10-21 16:53:32 +00:00
import {
search ,
convertShortName ,
isShortName ,
convertShortNameToData ,
} from '../../components/emoji/lib' ;
import { Emoji } from '../../components/emoji/Emoji' ;
2021-10-26 19:15:33 +00:00
import type { EmojiPickDataType } from '../../components/emoji/EmojiPicker' ;
2020-11-05 21:18:42 +00:00
import { getBlotTextPartitions , matchBlotTextPartitions } from '../util' ;
2022-08-18 15:02:13 +00:00
import * as log from '../../logging/log' ;
2020-10-21 16:53:32 +00:00
2021-01-07 21:39:17 +00:00
const Keyboard = Quill . import ( 'modules/keyboard' ) ;
2021-01-14 18:07:05 +00:00
type EmojiPickerOptions = {
2020-10-21 16:53:32 +00:00
onPickEmoji : ( emoji : EmojiPickDataType ) = > void ;
setEmojiPickerElement : ( element : JSX.Element | null ) = > void ;
skinTone : number ;
2021-01-14 18:07:05 +00:00
} ;
2020-10-21 16:53:32 +00:00
export class EmojiCompletion {
results : Array < EmojiData > ;
index : number ;
options : EmojiPickerOptions ;
root : HTMLDivElement ;
quill : Quill ;
constructor ( quill : Quill , options : EmojiPickerOptions ) {
this . results = [ ] ;
this . index = 0 ;
this . options = options ;
this . root = document . body . appendChild ( document . createElement ( 'div' ) ) ;
this . quill = quill ;
const clearResults = ( ) = > {
if ( this . results . length ) {
this . reset ( ) ;
}
return true ;
} ;
const changeIndex = ( by : number ) = > ( ) : boolean = > {
if ( this . results . length ) {
this . changeIndex ( by ) ;
return false ;
}
return true ;
} ;
2021-01-07 21:39:17 +00:00
this . quill . keyboard . addBinding ( { key : Keyboard.keys.UP } , changeIndex ( - 1 ) ) ;
this . quill . keyboard . addBinding ( { key : Keyboard.keys.RIGHT } , clearResults ) ;
this . quill . keyboard . addBinding ( { key : Keyboard.keys.DOWN } , changeIndex ( 1 ) ) ;
this . quill . keyboard . addBinding ( { key : Keyboard.keys.LEFT } , clearResults ) ;
2020-11-11 22:54:23 +00:00
this . quill . keyboard . addBinding (
{
// 186 + Shift = Colon
key : 186 ,
shiftKey : true ,
} ,
( ) = > this . onTextChange ( true )
) ;
this . quill . keyboard . addBinding (
{
// 58 = Also Colon
key : 58 ,
} ,
( ) = > this . onTextChange ( true )
) ;
2020-10-21 16:53:32 +00:00
2020-11-11 22:54:23 +00:00
this . quill . on (
'text-change' ,
_ . debounce ( ( ) = > this . onTextChange ( ) , 100 )
) ;
2020-11-03 01:19:52 +00:00
this . quill . on ( 'selection-change' , this . onSelectionChange . bind ( this ) ) ;
2020-10-21 16:53:32 +00:00
}
destroy ( ) : void {
this . root . remove ( ) ;
}
changeIndex ( by : number ) : void {
this . index = ( this . index + by + this . results . length ) % this . results . length ;
this . render ( ) ;
}
getCurrentLeafTextPartitions ( ) : [ string , string ] {
const range = this . quill . getSelection ( ) ;
2020-11-05 21:18:42 +00:00
const [ blot , index ] = this . quill . getLeaf ( range ? range . index : - 1 ) ;
2020-10-21 16:53:32 +00:00
2021-09-17 16:21:33 +00:00
return getBlotTextPartitions ( blot . text , index ) ;
2020-10-21 16:53:32 +00:00
}
2020-11-03 01:19:52 +00:00
onSelectionChange ( ) : void {
// Selection should never change while we're editing an emoji
this . reset ( ) ;
}
2020-11-11 22:54:23 +00:00
onTextChange ( justPressedColon = false ) : boolean {
const PASS_THROUGH = true ;
const INTERCEPT = false ;
2020-10-21 16:53:32 +00:00
const range = this . quill . getSelection ( ) ;
2020-11-11 22:54:23 +00:00
if ( ! range ) return PASS_THROUGH ;
2020-10-21 16:53:32 +00:00
2020-11-05 21:18:42 +00:00
const [ blot , index ] = this . quill . getLeaf ( range . index ) ;
const [ leftTokenTextMatch , rightTokenTextMatch ] = matchBlotTextPartitions (
blot ,
index ,
2021-02-04 22:40:26 +00:00
/(?<=^|\s):([-+0-9a-zA-Z_]*)(:?)$/ ,
/^([-+0-9a-zA-Z_]*):/
2020-10-27 00:13:49 +00:00
) ;
2020-10-21 16:53:32 +00:00
2020-11-05 21:18:42 +00:00
if ( leftTokenTextMatch ) {
const [ , leftTokenText , isSelfClosing ] = leftTokenTextMatch ;
2020-10-21 16:53:32 +00:00
2020-11-11 22:54:23 +00:00
if ( isSelfClosing || justPressedColon ) {
2020-11-05 21:18:42 +00:00
if ( isShortName ( leftTokenText ) ) {
const emojiData = convertShortNameToData (
leftTokenText ,
this . options . skinTone
2020-10-21 16:53:32 +00:00
) ;
2020-11-05 21:18:42 +00:00
2020-11-11 22:54:23 +00:00
const numberOfColons = isSelfClosing ? 2 : 1 ;
2020-11-05 21:18:42 +00:00
if ( emojiData ) {
this . insertEmoji (
emojiData ,
2020-11-11 22:54:23 +00:00
range . index - leftTokenText . length - numberOfColons ,
leftTokenText . length + numberOfColons
2020-11-05 21:18:42 +00:00
) ;
2020-11-11 22:54:23 +00:00
return INTERCEPT ;
2020-11-05 21:18:42 +00:00
}
} else {
this . reset ( ) ;
2020-11-11 22:54:23 +00:00
return PASS_THROUGH ;
2020-10-21 16:53:32 +00:00
}
}
2020-11-05 21:18:42 +00:00
if ( rightTokenTextMatch ) {
const [ , rightTokenText ] = rightTokenTextMatch ;
const tokenText = leftTokenText + rightTokenText ;
if ( isShortName ( tokenText ) ) {
const emojiData = convertShortNameToData (
tokenText ,
this . options . skinTone
2020-10-21 16:53:32 +00:00
) ;
2020-11-05 21:18:42 +00:00
if ( emojiData ) {
this . insertEmoji (
emojiData ,
range . index - leftTokenText . length - 1 ,
tokenText . length + 2
) ;
2020-11-11 22:54:23 +00:00
return INTERCEPT ;
2020-11-05 21:18:42 +00:00
}
2020-10-21 16:53:32 +00:00
}
}
2021-11-11 15:59:18 +00:00
if ( leftTokenText . length < 3 ) {
2020-11-05 21:18:42 +00:00
this . reset ( ) ;
2020-11-11 22:54:23 +00:00
return PASS_THROUGH ;
2020-11-05 21:18:42 +00:00
}
2020-10-21 16:53:32 +00:00
2020-11-05 21:18:42 +00:00
const showEmojiResults = search ( leftTokenText , 10 ) ;
2020-10-21 16:53:32 +00:00
2020-11-05 21:18:42 +00:00
if ( showEmojiResults . length > 0 ) {
this . results = showEmojiResults ;
2021-01-07 21:39:17 +00:00
this . index = Math . min ( this . results . length - 1 , this . index ) ;
2020-11-05 21:18:42 +00:00
this . render ( ) ;
} else if ( this . results . length !== 0 ) {
this . reset ( ) ;
}
} else if ( this . results . length !== 0 ) {
2020-10-21 16:53:32 +00:00
this . reset ( ) ;
}
2020-11-11 22:54:23 +00:00
return PASS_THROUGH ;
2020-10-21 16:53:32 +00:00
}
completeEmoji ( ) : void {
const range = this . quill . getSelection ( ) ;
if ( range === null ) return ;
const emoji = this . results [ this . index ] ;
const [ leafText ] = this . getCurrentLeafTextPartitions ( ) ;
const tokenTextMatch = /:([-+0-9a-z_]*)(:?)$/ . exec ( leafText ) ;
if ( tokenTextMatch === null ) return ;
const [ , tokenText ] = tokenTextMatch ;
this . insertEmoji (
emoji ,
range . index - tokenText . length - 1 ,
tokenText . length + 1 ,
true
) ;
}
insertEmoji (
emojiData : EmojiData ,
index : number ,
range : number ,
withTrailingSpace = false
) : void {
const emoji = convertShortName ( emojiData . short_name , this . options . skinTone ) ;
2020-11-18 15:15:42 +00:00
const delta = new Delta ( ) . retain ( index ) . delete ( range ) . insert ( { emoji } ) ;
2020-10-21 16:53:32 +00:00
if ( withTrailingSpace ) {
this . quill . updateContents ( delta . insert ( ' ' ) , 'user' ) ;
this . quill . setSelection ( index + 2 , 0 , 'user' ) ;
} else {
this . quill . updateContents ( delta , 'user' ) ;
this . quill . setSelection ( index + 1 , 0 , 'user' ) ;
}
this . options . onPickEmoji ( {
shortName : emojiData.short_name ,
skinTone : this.options.skinTone ,
} ) ;
this . reset ( ) ;
}
reset ( ) : void {
if ( this . results . length ) {
this . results = [ ] ;
this . index = 0 ;
this . render ( ) ;
}
}
onUnmount ( ) : void {
document . body . removeChild ( this . root ) ;
}
render ( ) : void {
const { results : emojiResults , index : emojiResultsIndex } = this ;
if ( emojiResults . length === 0 ) {
this . options . setEmojiPickerElement ( null ) ;
return ;
}
2022-08-18 15:02:13 +00:00
// a virtual reference to the text we are trying to auto-complete
const reference : VirtualElement = {
getBoundingClientRect() {
const selection = window . getSelection ( ) ;
// there's a selection and at least one range
if ( selection !== null && selection . rangeCount !== 0 ) {
// grab the first range, the one the user is actually on right now
// clone it so we don't actually modify the user's selection/caret position
const range = selection . getRangeAt ( 0 ) . cloneRange ( ) ;
// if for any reason the range is a selection (not just a caret)
// collapse it to just a caret, so we can walk it back to the :word
range . collapse ( true ) ;
// if we can, position the popper at the beginning of the emoji text (:word)
const endContainerTextContent = range . endContainer . textContent ;
const startOfEmojiText = endContainerTextContent ? . lastIndexOf ( ':' ) ;
if (
endContainerTextContent &&
isNumber ( startOfEmojiText ) &&
startOfEmojiText !== - 1
) {
range . setStart (
range . endContainer ,
range . endOffset -
( endContainerTextContent . length - startOfEmojiText )
) ;
} else {
log . warn (
` Could not find the beginning of the emoji word to be completed. startOfEmojiText= ${ startOfEmojiText } , endContainerTextContent.length= ${ endContainerTextContent ? . length } , range.offsets= ${ range . startOffset } - ${ range . endOffset } `
) ;
}
return range . getClientRects ( ) [ 0 ] ;
}
log . warn ( 'No selection range when auto-completing emoji' ) ;
return new DOMRect ( ) ; // don't crash just because we couldn't get a rectangle
} ,
} ;
2020-10-21 16:53:32 +00:00
const element = createPortal (
2022-08-18 15:02:13 +00:00
< Popper placement = "top-start" referenceElement = { reference } >
2020-10-21 16:53:32 +00:00
{ ( { ref , style } ) = > (
< div
ref = { ref }
2020-11-03 01:19:52 +00:00
className = "module-composition-input__suggestions"
2020-10-21 16:53:32 +00:00
style = { style }
role = "listbox"
aria - expanded
aria - activedescendant = { ` emoji-result-- ${
emojiResults . length
? emojiResults [ emojiResultsIndex ] . short_name
: ''
} ` }
tabIndex = { 0 }
>
{ emojiResults . map ( ( emoji , index ) = > (
< button
type = "button"
key = { emoji . short_name }
id = { ` emoji-result-- ${ emoji . short_name } ` }
role = "option button"
aria - selected = { emojiResultsIndex === index }
onClick = { ( ) = > {
this . index = index ;
this . completeEmoji ( ) ;
} }
className = { classNames (
2020-11-03 01:19:52 +00:00
'module-composition-input__suggestions__row' ,
2020-10-21 16:53:32 +00:00
emojiResultsIndex === index
2020-11-03 01:19:52 +00:00
? 'module-composition-input__suggestions__row--selected'
2020-10-21 16:53:32 +00:00
: null
) }
>
< Emoji
shortName = { emoji . short_name }
size = { 16 }
skinTone = { this . options . skinTone }
/ >
2020-11-03 01:19:52 +00:00
< div className = "module-composition-input__suggestions__row__short-name" >
2020-10-21 16:53:32 +00:00
: { emoji . short_name } :
< / div >
< / button >
) ) }
< / div >
) }
< / Popper > ,
2022-08-18 15:02:13 +00:00
document . body
2020-10-21 16:53:32 +00:00
) ;
this . options . setEmojiPickerElement ( element ) ;
}
}