1- import { LexicalEditor , TextNode } from "lexical" ;
1+ import {
2+ $createTextNode ,
3+ $getSelection , $isRangeSelection ,
4+ COMMAND_PRIORITY_NORMAL , RangeSelection , TextNode
5+ } from "lexical" ;
6+ import { KEY_AT_COMMAND } from "lexical/LexicalCommands" ;
7+ import { $createMentionNode } from "@lexical/link/LexicalMentionNode" ;
8+ import { el , htmlToDom } from "../utils/dom" ;
9+ import { EditorUiContext } from "../ui/framework/core" ;
10+ import { debounce } from "../../services/util" ;
11+ import { removeLoading , showLoading } from "../../services/dom" ;
212
313
4- export function registerMentions ( editor : LexicalEditor ) : ( ) => void {
14+ function enterUserSelectMode ( context : EditorUiContext , selection : RangeSelection ) {
15+ const textNode = selection . getNodes ( ) [ 0 ] as TextNode ;
16+ const selectionPos = selection . getStartEndPoints ( ) ;
17+ if ( ! selectionPos ) {
18+ return ;
19+ }
520
6- const unregisterTransform = editor . registerNodeTransform ( TextNode , ( node : TextNode ) => {
7- console . log ( node ) ;
8- // TODO - If last character is @, show autocomplete selector list of users.
9- // Filter list by any extra characters entered.
10- // On enter, replace with name mention element.
11- // On space/escape, hide autocomplete list.
21+ const offset = selectionPos [ 0 ] . offset ;
22+
23+ // Ignore if the @ sign is not after a space or the start of the line
24+ const atStart = offset === 0 ;
25+ const afterSpace = textNode . getTextContent ( ) . charAt ( offset - 1 ) === ' ' ;
26+ if ( ! atStart && ! afterSpace ) {
27+ return ;
28+ }
29+
30+ const split = textNode . splitText ( offset ) ;
31+ const newNode = split [ atStart ? 0 : 1 ] ;
32+
33+ const mention = $createMentionNode ( 0 , '' , '' ) ;
34+ newNode . replace ( mention ) ;
35+ mention . select ( ) ;
36+
37+ const revertEditorMention = ( ) => {
38+ context . editor . update ( ( ) => {
39+ const text = $createTextNode ( '@' ) ;
40+ mention . replace ( text ) ;
41+ text . selectEnd ( ) ;
42+ } ) ;
43+ } ;
44+
45+ requestAnimationFrame ( ( ) => {
46+ const mentionDOM = context . editor . getElementByKey ( mention . getKey ( ) ) ;
47+ if ( ! mentionDOM ) {
48+ revertEditorMention ( ) ;
49+ return ;
50+ }
51+
52+ const selectList = buildAndShowUserSelectorAtElement ( context , mentionDOM ) ;
53+ handleUserListLoading ( selectList ) ;
54+ handleUserSelectCancel ( context , selectList , revertEditorMention ) ;
1255 } ) ;
1356
57+
58+ // TODO - On enter, replace with name mention element.
59+ }
60+
61+ function handleUserSelectCancel ( context : EditorUiContext , selectList : HTMLElement , revertEditorMention : ( ) => void ) {
62+ const controller = new AbortController ( ) ;
63+
64+ const onCancel = ( ) => {
65+ revertEditorMention ( ) ;
66+ selectList . remove ( ) ;
67+ controller . abort ( ) ;
68+ }
69+
70+ selectList . addEventListener ( 'keydown' , ( event ) => {
71+ if ( event . key === 'Escape' ) {
72+ onCancel ( ) ;
73+ }
74+ } , { signal : controller . signal } ) ;
75+
76+ const input = selectList . querySelector ( 'input' ) as HTMLInputElement ;
77+ input . addEventListener ( 'keydown' , ( event ) => {
78+ if ( event . key === 'Backspace' && input . value === '' ) {
79+ onCancel ( ) ;
80+ event . preventDefault ( ) ;
81+ event . stopPropagation ( ) ;
82+ }
83+ } , { signal : controller . signal } ) ;
84+
85+ context . editorDOM . addEventListener ( 'click' , ( event ) => {
86+ onCancel ( )
87+ } , { signal : controller . signal } ) ;
88+ context . editorDOM . addEventListener ( 'keydown' , ( event ) => {
89+ onCancel ( ) ;
90+ } , { signal : controller . signal } ) ;
91+ }
92+
93+ function handleUserListLoading ( selectList : HTMLElement ) {
94+ const cache = new Map < string , string > ( ) ;
95+
96+ const updateUserList = async ( searchTerm : string ) => {
97+ // Empty list
98+ for ( const child of [ ...selectList . children ] . slice ( 1 ) ) {
99+ child . remove ( ) ;
100+ }
101+
102+ // Fetch new content
103+ let responseHtml = '' ;
104+ if ( cache . has ( searchTerm ) ) {
105+ responseHtml = cache . get ( searchTerm ) || '' ;
106+ } else {
107+ const loadingWrap = el ( 'li' ) ;
108+ showLoading ( loadingWrap ) ;
109+ selectList . appendChild ( loadingWrap ) ;
110+
111+ const resp = await window . $http . get ( `/search/users/mention?search=${ searchTerm } ` ) ;
112+ responseHtml = resp . data as string ;
113+ cache . set ( searchTerm , responseHtml ) ;
114+ loadingWrap . remove ( ) ;
115+ }
116+
117+ const doc = htmlToDom ( responseHtml ) ;
118+ const toInsert = doc . querySelectorAll ( 'li' ) ;
119+ for ( const listEl of toInsert ) {
120+ const adopted = window . document . adoptNode ( listEl ) as HTMLElement ;
121+ selectList . appendChild ( adopted ) ;
122+ }
123+
124+ } ;
125+
126+ // Initial load
127+ updateUserList ( '' ) ;
128+
129+ const input = selectList . querySelector ( 'input' ) as HTMLInputElement ;
130+ const updateUserListDebounced = debounce ( updateUserList , 200 , false ) ;
131+ input . addEventListener ( 'input' , ( ) => {
132+ const searchTerm = input . value ;
133+ updateUserListDebounced ( searchTerm ) ;
134+ } ) ;
135+ }
136+
137+ function buildAndShowUserSelectorAtElement ( context : EditorUiContext , mentionDOM : HTMLElement ) : HTMLElement {
138+ const searchInput = el ( 'input' , { type : 'text' } ) ;
139+ const searchItem = el ( 'li' , { } , [ searchInput ] ) ;
140+ const userSelect = el ( 'ul' , { class : 'suggestion-box dropdown-menu' } , [ searchItem ] ) ;
141+
142+ context . containerDOM . appendChild ( userSelect ) ;
143+
144+ userSelect . style . display = 'block' ;
145+ userSelect . style . top = '0' ;
146+ userSelect . style . left = '0' ;
147+ const mentionPos = mentionDOM . getBoundingClientRect ( ) ;
148+ const userSelectPos = userSelect . getBoundingClientRect ( ) ;
149+ userSelect . style . top = `${ mentionPos . bottom - userSelectPos . top + 3 } px` ;
150+ userSelect . style . left = `${ mentionPos . left - userSelectPos . left } px` ;
151+
152+ searchInput . focus ( ) ;
153+
154+ return userSelect ;
155+ }
156+
157+ export function registerMentions ( context : EditorUiContext ) : ( ) => void {
158+ const editor = context . editor ;
159+
160+ const unregisterCommand = editor . registerCommand ( KEY_AT_COMMAND , function ( event : KeyboardEvent ) : boolean {
161+ const selection = $getSelection ( ) ;
162+ if ( $isRangeSelection ( selection ) && selection . isCollapsed ( ) ) {
163+ window . setTimeout ( ( ) => {
164+ editor . update ( ( ) => {
165+ enterUserSelectMode ( context , selection ) ;
166+ } ) ;
167+ } , 1 ) ;
168+ }
169+ return false ;
170+ } , COMMAND_PRIORITY_NORMAL ) ;
171+
14172 return ( ) : void => {
15- unregisterTransform ( ) ;
173+ unregisterCommand ( ) ;
16174 } ;
17175}
0 commit comments