1- import React , { useRef , useEffect , forwardRef , useImperativeHandle , useCallback , useMemo } from 'react' ;
1+ import React , { useRef , useEffect , forwardRef , useImperativeHandle , useCallback , useMemo , useState } from 'react' ;
22import { useTheme } from '../hooks/useTheme' ;
33import { MONACO_KEYBINDING_DEFINITIONS } from '../services/editor/monacoKeybindings' ;
44import { DEFAULT_SETTINGS } from '../constants' ;
55import { ensureMonaco } from '../services/editor/monacoLoader' ;
66import { applyDocforgeTheme } from '../services/editor/monacoTheme' ;
77import { registerTomlLanguage } from '../services/editor/registerTomlLanguage' ;
88import { registerPlantumlLanguage } from '../services/editor/registerPlantumlLanguage' ;
9+ import EmojiPickerOverlay from './EmojiPickerOverlay' ;
910
1011// Let TypeScript know monaco is available on the window
1112declare const monaco : any ;
@@ -34,6 +35,13 @@ const LETTER_REGEX = /^[A-Z]$/;
3435const DIGIT_REGEX = / ^ [ 0 - 9 ] $ / ;
3536const FUNCTION_KEY_REGEX = / ^ F ( [ 1 - 9 ] | 1 [ 0 - 2 ] ) $ / ;
3637
38+ type StoredSelection = {
39+ startLineNumber : number ;
40+ startColumn : number ;
41+ endLineNumber : number ;
42+ endColumn : number ;
43+ } ;
44+
3745const toMonacoKeyCode = ( monacoApi : any , key : string ) : number | null => {
3846 const normalized = key . length === 1 ? key . toUpperCase ( ) : key ;
3947
@@ -123,12 +131,16 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
123131 const editorRef = useRef < HTMLDivElement > ( null ) ;
124132 const monacoInstanceRef = useRef < any > ( null ) ;
125133 const monacoApiRef = useRef < any > ( null ) ;
134+ const emojiPickerStateRef = useRef < { selection : StoredSelection | null ; anchor : { x : number ; y : number } | null } | null > ( null ) ;
126135 const { theme } = useTheme ( ) ;
127136 const contentRef = useRef ( content ) ;
128137 const customShortcutsRef = useRef < Record < string , string [ ] > > ( { } ) ;
129138 const actionDisposablesRef = useRef < Array < { dispose : ( ) => void } > > ( [ ] ) ;
130139 const focusDisposableRef = useRef < { dispose : ( ) => void } | null > ( null ) ;
131140 const blurDisposableRef = useRef < { dispose : ( ) => void } | null > ( null ) ;
141+ const emojiActionDisposableRef = useRef < { dispose : ( ) => void } | null > ( null ) ;
142+ const lastContextMenuCoordsRef = useRef < { x : number ; y : number } | null > ( null ) ;
143+ const [ emojiPickerState , setEmojiPickerState ] = useState < { selection : StoredSelection | null ; anchor : { x : number ; y : number } | null } | null > ( null ) ;
132144 const computedFontFamily = useMemo ( ( ) => {
133145 const candidate = ( fontFamily ?? '' ) . trim ( ) ;
134146 return candidate || DEFAULT_SETTINGS . editorFontFamily ;
@@ -164,6 +176,176 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
164176 highlightColorRef . current = computedActiveLineHighlightColor ;
165177 } , [ computedActiveLineHighlightColor ] ) ;
166178
179+ const calculateAnchorFromSelection = useCallback ( ( selection : StoredSelection | null ) : { x : number ; y : number } | null => {
180+ if ( ! selection || ! editorRef . current || ! monacoInstanceRef . current ) {
181+ return null ;
182+ }
183+
184+ const editor = monacoInstanceRef . current ;
185+ const endPosition = {
186+ lineNumber : selection . endLineNumber ,
187+ column : selection . endColumn ,
188+ } ;
189+
190+ let scrolled = editor . getScrolledVisiblePosition ( endPosition ) ;
191+ if ( ! scrolled ) {
192+ editor . revealPositionInCenter ( endPosition ) ;
193+ scrolled = editor . getScrolledVisiblePosition ( endPosition ) ;
194+ }
195+
196+ if ( ! scrolled ) {
197+ return null ;
198+ }
199+
200+ const containerRect = editorRef . current . getBoundingClientRect ( ) ;
201+ return {
202+ x : containerRect . left + scrolled . left ,
203+ y : containerRect . top + scrolled . top + scrolled . height ,
204+ } ;
205+ } , [ ] ) ;
206+
207+ const updateEmojiPickerAnchor = useCallback ( ( preferredCoords ?: { x : number ; y : number } | null ) => {
208+ const state = emojiPickerStateRef . current ;
209+ if ( ! state ) {
210+ return ;
211+ }
212+
213+ let anchor = preferredCoords ?? null ;
214+ if ( ! anchor ) {
215+ anchor = calculateAnchorFromSelection ( state . selection ) ;
216+ }
217+
218+ if ( ! anchor && editorRef . current ) {
219+ const rect = editorRef . current . getBoundingClientRect ( ) ;
220+ anchor = {
221+ x : rect . left + rect . width / 2 ,
222+ y : rect . top + rect . height / 2 ,
223+ } ;
224+ }
225+
226+ if ( ! anchor ) {
227+ return ;
228+ }
229+
230+ emojiPickerStateRef . current = { ...state , anchor } ;
231+ setEmojiPickerState ( ( previous ) => ( previous ? { ...previous , anchor } : previous ) ) ;
232+ } , [ calculateAnchorFromSelection ] ) ;
233+
234+ const captureCurrentSelection = useCallback ( ( ) : StoredSelection | null => {
235+ const editor = monacoInstanceRef . current ;
236+ if ( ! editor ) {
237+ return null ;
238+ }
239+
240+ const selection = editor . getSelection ( ) ;
241+ if ( selection ) {
242+ return {
243+ startLineNumber : selection . startLineNumber ,
244+ startColumn : selection . startColumn ,
245+ endLineNumber : selection . endLineNumber ,
246+ endColumn : selection . endColumn ,
247+ } ;
248+ }
249+
250+ const position = editor . getPosition ( ) ;
251+ if ( ! position ) {
252+ return null ;
253+ }
254+
255+ return {
256+ startLineNumber : position . lineNumber ,
257+ startColumn : position . column ,
258+ endLineNumber : position . lineNumber ,
259+ endColumn : position . column ,
260+ } ;
261+ } , [ ] ) ;
262+
263+ const openEmojiPicker = useCallback ( ( selection : StoredSelection | null , coords : { x : number ; y : number } | null ) => {
264+ const effectiveSelection = selection ?? captureCurrentSelection ( ) ;
265+ const anchor = coords ?? calculateAnchorFromSelection ( effectiveSelection ) ;
266+
267+ let resolvedAnchor = anchor ;
268+ if ( ! resolvedAnchor && editorRef . current ) {
269+ const rect = editorRef . current . getBoundingClientRect ( ) ;
270+ resolvedAnchor = {
271+ x : rect . left + rect . width / 2 ,
272+ y : rect . top + rect . height / 2 ,
273+ } ;
274+ }
275+
276+ const nextState = {
277+ selection : effectiveSelection ,
278+ anchor : resolvedAnchor ,
279+ } ;
280+
281+ emojiPickerStateRef . current = nextState ;
282+ setEmojiPickerState ( nextState ) ;
283+
284+ requestAnimationFrame ( ( ) => {
285+ updateEmojiPickerAnchor ( coords ?? null ) ;
286+ } ) ;
287+ } , [ captureCurrentSelection , calculateAnchorFromSelection , updateEmojiPickerAnchor ] ) ;
288+
289+ const insertEmoji = useCallback ( ( emoji : string ) => {
290+ const editor = monacoInstanceRef . current ;
291+ const monacoApi = monacoApiRef . current ;
292+ if ( ! editor || ! monacoApi ) {
293+ return ;
294+ }
295+
296+ const selection = emojiPickerStateRef . current ?. selection ?? captureCurrentSelection ( ) ;
297+ if ( ! selection ) {
298+ editor . trigger ( 'emoji-picker' , 'type' , { text : emoji } ) ;
299+ return ;
300+ }
301+
302+ const range = new monacoApi . Range (
303+ selection . startLineNumber ,
304+ selection . startColumn ,
305+ selection . endLineNumber ,
306+ selection . endColumn ,
307+ ) ;
308+
309+ editor . executeEdits ( 'emoji-picker' , [
310+ {
311+ range,
312+ text : emoji ,
313+ forceMoveMarkers : true ,
314+ } ,
315+ ] ) ;
316+
317+ const newColumn = selection . startColumn + emoji . length ;
318+ const newSelection = new monacoApi . Selection (
319+ selection . startLineNumber ,
320+ newColumn ,
321+ selection . startLineNumber ,
322+ newColumn ,
323+ ) ;
324+ editor . setSelection ( newSelection ) ;
325+ editor . focus ( ) ;
326+ contentRef . current = editor . getValue ( ) ;
327+ } , [ captureCurrentSelection ] ) ;
328+
329+ useEffect ( ( ) => {
330+ emojiPickerStateRef . current = emojiPickerState ;
331+ } , [ emojiPickerState ] ) ;
332+
333+ useEffect ( ( ) => {
334+ if ( ! emojiPickerState ) {
335+ return ;
336+ }
337+ updateEmojiPickerAnchor ( emojiPickerState . anchor ?? null ) ;
338+
339+ const handleResize = ( ) => {
340+ updateEmojiPickerAnchor ( emojiPickerState . anchor ?? null ) ;
341+ } ;
342+
343+ window . addEventListener ( 'resize' , handleResize ) ;
344+ return ( ) => {
345+ window . removeEventListener ( 'resize' , handleResize ) ;
346+ } ;
347+ } , [ emojiPickerState , updateEmojiPickerAnchor ] ) ;
348+
167349 useImperativeHandle ( ref , ( ) => ( {
168350 format ( ) {
169351 monacoInstanceRef . current ?. getAction ( 'editor.action.formatDocument' ) ?. run ( ) ;
@@ -203,6 +385,8 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
203385 }
204386 } ) ;
205387 actionDisposablesRef . current = [ ] ;
388+ emojiActionDisposableRef . current ?. dispose ( ) ;
389+ emojiActionDisposableRef . current = null ;
206390 } , [ ] ) ;
207391
208392 const disposeFocusListeners = useCallback ( ( ) => {
@@ -303,6 +487,29 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
303487 readOnly,
304488 } ) ;
305489
490+ const storeSelection = ( ) => {
491+ const selection = editorInstance . getSelection ( ) ;
492+ if ( ! selection ) {
493+ if ( emojiPickerStateRef . current ) {
494+ const next = { ...emojiPickerStateRef . current , selection : null } ;
495+ emojiPickerStateRef . current = next ;
496+ setEmojiPickerState ( prev => ( prev ? { ...prev , selection : null } : prev ) ) ;
497+ }
498+ return ;
499+ }
500+ const storedSelection : StoredSelection = {
501+ startLineNumber : selection . startLineNumber ,
502+ startColumn : selection . startColumn ,
503+ endLineNumber : selection . endLineNumber ,
504+ endColumn : selection . endColumn ,
505+ } ;
506+ if ( emojiPickerStateRef . current ) {
507+ const next = { ...emojiPickerStateRef . current , selection : storedSelection } ;
508+ emojiPickerStateRef . current = next ;
509+ setEmojiPickerState ( prev => ( prev ? { ...prev , selection : storedSelection } : prev ) ) ;
510+ }
511+ } ;
512+
306513 editorInstance . onDidChangeModelContent ( ( ) => {
307514 const currentValue = editorInstance . getValue ( ) ;
308515 if ( currentValue !== contentRef . current ) {
@@ -318,6 +525,17 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
318525 clientHeight : editorInstance . getLayoutInfo ( ) . height
319526 } ) ;
320527 }
528+ updateEmojiPickerAnchor ( ) ;
529+ } ) ;
530+
531+ editorInstance . onContextMenu ( ( event : any ) => {
532+ const contextEvent = event ?. event ;
533+ const posx = contextEvent ?. posx ?? contextEvent ?. browserEvent ?. clientX ;
534+ const posy = contextEvent ?. posy ?? contextEvent ?. browserEvent ?. clientY ;
535+ if ( typeof posx === 'number' && typeof posy === 'number' ) {
536+ lastContextMenuCoordsRef . current = { x : posx , y : posy } ;
537+ }
538+ storeSelection ( ) ;
321539 } ) ;
322540
323541 disposeFocusListeners ( ) ;
@@ -332,6 +550,19 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
332550
333551 monacoInstanceRef . current = editorInstance ;
334552 applyEditorShortcuts ( ) ;
553+ emojiActionDisposableRef . current ?. dispose ( ) ;
554+ emojiActionDisposableRef . current = editorInstance . addAction ( {
555+ id : 'docforge.insertEmoji' ,
556+ label : 'Insert Emoji…' ,
557+ contextMenuGroupId : 'navigation' ,
558+ contextMenuOrder : 0.5 ,
559+ run : ( ) => {
560+ const state = captureCurrentSelection ( ) ;
561+ const coords = lastContextMenuCoordsRef . current ;
562+ openEmojiPicker ( state , coords ?? null ) ;
563+ lastContextMenuCoordsRef . current = null ;
564+ } ,
565+ } ) ;
335566 } catch ( error ) {
336567 // eslint-disable-next-line no-console
337568 console . error ( 'Failed to initialize Monaco editor' , error ) ;
@@ -348,6 +579,8 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
348579 monacoInstanceRef . current . dispose ( ) ;
349580 monacoInstanceRef . current = null ;
350581 }
582+ emojiActionDisposableRef . current ?. dispose ( ) ;
583+ emojiActionDisposableRef . current = null ;
351584 monacoApiRef . current = null ;
352585 } ;
353586 } , [ onChange , onScroll , applyEditorShortcuts , disposeEditorShortcuts , disposeFocusListeners , computedFontFamily , computedFontSize , readOnly , onFocusChange ] ) ;
@@ -392,7 +625,23 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
392625 } , [ language ] ) ;
393626
394627
395- return < div ref = { editorRef } className = "w-full h-full" /> ;
628+ return (
629+ < >
630+ < div ref = { editorRef } className = "w-full h-full" />
631+ < EmojiPickerOverlay
632+ isOpen = { Boolean ( emojiPickerState ) }
633+ anchor = { emojiPickerState ?. anchor ?? null }
634+ onClose = { ( ) => {
635+ setEmojiPickerState ( null ) ;
636+ monacoInstanceRef . current ?. focus ( ) ;
637+ } }
638+ onSelectEmoji = { ( emoji ) => {
639+ insertEmoji ( emoji ) ;
640+ } }
641+ ariaLabel = "Insert emoji into editor"
642+ />
643+ </ >
644+ ) ;
396645} ) ;
397646
398647export default CodeEditor ;
0 commit comments