Skip to content

Commit 9901196

Browse files
committed
Add emoji picker integration for editor and tree titles
1 parent 232396c commit 9901196

5 files changed

Lines changed: 514 additions & 16 deletions

File tree

components/CodeEditor.tsx

Lines changed: 251 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import React, { useRef, useEffect, forwardRef, useImperativeHandle, useCallback, useMemo } from 'react';
1+
import React, { useRef, useEffect, forwardRef, useImperativeHandle, useCallback, useMemo, useState } from 'react';
22
import { useTheme } from '../hooks/useTheme';
33
import { MONACO_KEYBINDING_DEFINITIONS } from '../services/editor/monacoKeybindings';
44
import { DEFAULT_SETTINGS } from '../constants';
55
import { ensureMonaco } from '../services/editor/monacoLoader';
66
import { applyDocforgeTheme } from '../services/editor/monacoTheme';
77
import { registerTomlLanguage } from '../services/editor/registerTomlLanguage';
88
import { registerPlantumlLanguage } from '../services/editor/registerPlantumlLanguage';
9+
import EmojiPickerOverlay from './EmojiPickerOverlay';
910

1011
// Let TypeScript know monaco is available on the window
1112
declare const monaco: any;
@@ -34,6 +35,13 @@ const LETTER_REGEX = /^[A-Z]$/;
3435
const DIGIT_REGEX = /^[0-9]$/;
3536
const 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+
3745
const 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

398647
export default CodeEditor;

0 commit comments

Comments
 (0)