Skip to content

Commit 25f38d2

Browse files
committed
Add emoji picker support to title editors
1 parent 9901196 commit 25f38d2

6 files changed

Lines changed: 212 additions & 2 deletions

File tree

components/FolderOverview.tsx

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import React, { useEffect, useRef, useState } from 'react';
1+
import React, { useCallback, useEffect, useRef, useState } from 'react';
22
import type { DocType, DocumentOrFolder } from '../types';
33
import Button from './Button';
44
import { FolderIcon, FileIcon, InfoIcon, PlusIcon, FolderPlusIcon, FolderDownIcon, PencilIcon, SearchIcon, XIcon, CopyIcon } from './Icons';
5+
import EmojiPickerOverlay from './EmojiPickerOverlay';
56

67
export interface DocTypeCount {
78
type: DocType;
@@ -126,12 +127,15 @@ const FolderOverview: React.FC<FolderOverviewProps> = ({
126127
const hasLanguageSummary = languageCounts.some(({ count }) => count > 0);
127128
const fileInputRef = useRef<HTMLInputElement | null>(null);
128129
const titleInputRef = useRef<HTMLInputElement | null>(null);
130+
const titleSelectionRef = useRef<{ start: number; end: number } | null>(null);
129131

130132
const normalizedTitle = folder.title?.trim() ?? '';
131133
const displayTitle = normalizedTitle.length > 0 ? normalizedTitle : 'Untitled Folder';
132134

133135
const [isEditingTitle, setIsEditingTitle] = useState(false);
134136
const [titleDraft, setTitleDraft] = useState(displayTitle);
137+
const [isTitleEmojiPickerOpen, setIsTitleEmojiPickerOpen] = useState(false);
138+
const [titleEmojiAnchor, setTitleEmojiAnchor] = useState<{ x: number; y: number } | null>(null);
135139

136140
useEffect(() => {
137141
setTitleDraft(displayTitle);
@@ -143,8 +147,17 @@ const FolderOverview: React.FC<FolderOverviewProps> = ({
143147
requestAnimationFrame(() => {
144148
titleInputRef.current?.focus();
145149
titleInputRef.current?.select();
150+
const value = titleInputRef.current?.value ?? titleDraft;
151+
titleSelectionRef.current = { start: 0, end: value.length };
146152
});
147153
}
154+
}, [isEditingTitle, titleDraft]);
155+
156+
useEffect(() => {
157+
if (!isEditingTitle) {
158+
setIsTitleEmojiPickerOpen(false);
159+
setTitleEmojiAnchor(null);
160+
}
148161
}, [isEditingTitle]);
149162

150163
const handleImportClick = () => {
@@ -175,6 +188,71 @@ const FolderOverview: React.FC<FolderOverviewProps> = ({
175188
setTitleDraft(event.target.value);
176189
};
177190

191+
const updateTitleSelection = useCallback(() => {
192+
const input = titleInputRef.current;
193+
if (!input) {
194+
return;
195+
}
196+
const start = input.selectionStart ?? input.value.length;
197+
const end = input.selectionEnd ?? input.value.length;
198+
titleSelectionRef.current = { start, end };
199+
}, []);
200+
201+
const closeTitleEmojiPicker = useCallback(() => {
202+
setIsTitleEmojiPickerOpen(false);
203+
setTitleEmojiAnchor(null);
204+
if (!isEditingTitle) {
205+
return;
206+
}
207+
requestAnimationFrame(() => {
208+
const input = titleInputRef.current;
209+
const selection = titleSelectionRef.current;
210+
if (input) {
211+
input.focus();
212+
if (selection) {
213+
input.setSelectionRange(selection.start, selection.end);
214+
}
215+
}
216+
});
217+
}, [isEditingTitle]);
218+
219+
const handleTitleEmojiSelect = useCallback((emoji: string) => {
220+
const input = titleInputRef.current;
221+
let selection = titleSelectionRef.current;
222+
223+
if (!selection) {
224+
if (input) {
225+
selection = {
226+
start: input.selectionStart ?? input.value.length,
227+
end: input.selectionEnd ?? input.value.length,
228+
};
229+
} else {
230+
const fallback = titleDraft.length;
231+
selection = { start: fallback, end: fallback };
232+
}
233+
}
234+
235+
const { start, end } = selection;
236+
237+
setTitleDraft((previous) => {
238+
const before = previous.slice(0, start);
239+
const after = previous.slice(end);
240+
return `${before}${emoji}${after}`;
241+
});
242+
243+
const caretPosition = start + emoji.length;
244+
titleSelectionRef.current = { start: caretPosition, end: caretPosition };
245+
closeTitleEmojiPicker();
246+
}, [closeTitleEmojiPicker, titleDraft.length]);
247+
248+
const handleTitleContextMenu = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
249+
event.preventDefault();
250+
event.stopPropagation();
251+
updateTitleSelection();
252+
setTitleEmojiAnchor({ x: event.clientX, y: event.clientY });
253+
setIsTitleEmojiPickerOpen(true);
254+
}, [updateTitleSelection]);
255+
178256
const handleTitleCancel = () => {
179257
setTitleDraft(displayTitle);
180258
setIsEditingTitle(false);
@@ -193,6 +271,9 @@ const FolderOverview: React.FC<FolderOverviewProps> = ({
193271
};
194272

195273
const handleTitleBlur: React.FocusEventHandler<HTMLInputElement> = () => {
274+
if (isTitleEmojiPickerOpen) {
275+
return;
276+
}
196277
commitTitleChange();
197278
};
198279

@@ -225,6 +306,10 @@ const FolderOverview: React.FC<FolderOverviewProps> = ({
225306
onChange={handleTitleChange}
226307
onBlur={handleTitleBlur}
227308
onKeyDown={handleTitleKeyDown}
309+
onContextMenu={handleTitleContextMenu}
310+
onSelect={updateTitleSelection}
311+
onKeyUp={updateTitleSelection}
312+
onMouseUp={updateTitleSelection}
228313
className="max-w-full rounded-sm border border-border-color bg-transparent px-1 py-1 text-xl font-semibold leading-tight text-text-main focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
229314
aria-label="Edit folder name"
230315
/>
@@ -548,6 +633,13 @@ const FolderOverview: React.FC<FolderOverviewProps> = ({
548633
</section>
549634
</div>
550635
</div>
636+
<EmojiPickerOverlay
637+
isOpen={isEditingTitle && isTitleEmojiPickerOpen}
638+
anchor={titleEmojiAnchor}
639+
onClose={closeTitleEmojiPicker}
640+
onSelectEmoji={handleTitleEmojiSelect}
641+
ariaLabel="Insert emoji into folder name"
642+
/>
551643
</div>
552644
);
553645
};

components/PromptEditor.tsx

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import PreviewPane from './PreviewPane';
1414
import LanguageDropdown from './LanguageDropdown';
1515
import PythonExecutionPanel from './PythonExecutionPanel';
1616
import ScriptExecutionPanel from './ScriptExecutionPanel';
17+
import EmojiPickerOverlay from './EmojiPickerOverlay';
1718

1819
interface DocumentEditorProps {
1920
documentNode: DocumentOrFolder;
@@ -121,6 +122,8 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
121122
const [isSaving, setIsSaving] = useState(false);
122123
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
123124
const [isGeneratingEmoji, setIsGeneratingEmoji] = useState(false);
125+
const [isTitleEmojiPickerOpen, setIsTitleEmojiPickerOpen] = useState(false);
126+
const [titleEmojiAnchor, setTitleEmojiAnchor] = useState<{ x: number; y: number } | null>(null);
124127
const [isCopied, setIsCopied] = useState(false);
125128
const [viewMode, setViewMode] = useState<ViewMode>(resolveDefaultViewMode(documentNode.default_view_mode, documentNode.language_hint));
126129
const [splitSize, setSplitSize] = useState(50);
@@ -169,6 +172,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
169172
const isResizing = useRef(false);
170173
const splitContainerRef = useRef<HTMLDivElement>(null);
171174
const acceptButtonRef = useRef<HTMLButtonElement>(null);
175+
const titleInputRef = useRef<HTMLInputElement>(null);
172176
const isContentInitialized = useRef(false);
173177
const editorRef = useRef<CodeEditorHandle>(null);
174178
const previewScrollRef = useRef<HTMLDivElement>(null);
@@ -178,6 +182,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
178182
const prevDocumentIdRef = useRef<string | null>(null);
179183
const prevDocumentContentRef = useRef<string | undefined>(undefined);
180184
const prevLockedRef = useRef(isLocked);
185+
const titleSelectionRef = useRef<{ start: number; end: number } | null>(null);
181186

182187
useEffect(() => {
183188
if (typeof window === 'undefined') return;
@@ -548,6 +553,71 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
548553
}
549554
};
550555

556+
const updateTitleSelection = useCallback(() => {
557+
const input = titleInputRef.current;
558+
if (!input) {
559+
return;
560+
}
561+
const start = input.selectionStart ?? input.value.length;
562+
const end = input.selectionEnd ?? input.value.length;
563+
titleSelectionRef.current = { start, end };
564+
}, []);
565+
566+
const closeTitleEmojiPicker = useCallback(() => {
567+
setIsTitleEmojiPickerOpen(false);
568+
setTitleEmojiAnchor(null);
569+
requestAnimationFrame(() => {
570+
const input = titleInputRef.current;
571+
const selection = titleSelectionRef.current;
572+
if (input) {
573+
input.focus();
574+
if (selection) {
575+
input.setSelectionRange(selection.start, selection.end);
576+
}
577+
}
578+
});
579+
}, []);
580+
581+
const handleTitleEmojiSelect = useCallback((emoji: string) => {
582+
const input = titleInputRef.current;
583+
let selection = titleSelectionRef.current;
584+
585+
if (!selection) {
586+
if (input) {
587+
selection = {
588+
start: input.selectionStart ?? input.value.length,
589+
end: input.selectionEnd ?? input.value.length,
590+
};
591+
} else {
592+
const fallback = title.length;
593+
selection = { start: fallback, end: fallback };
594+
}
595+
}
596+
597+
const { start, end } = selection;
598+
599+
setTitle((previous) => {
600+
const before = previous.slice(0, start);
601+
const after = previous.slice(end);
602+
return `${before}${emoji}${after}`;
603+
});
604+
605+
const caretPosition = start + emoji.length;
606+
titleSelectionRef.current = { start: caretPosition, end: caretPosition };
607+
closeTitleEmojiPicker();
608+
}, [closeTitleEmojiPicker, title.length]);
609+
610+
const handleTitleContextMenu = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
611+
if (isLocked) {
612+
return;
613+
}
614+
event.preventDefault();
615+
event.stopPropagation();
616+
updateTitleSelection();
617+
setTitleEmojiAnchor({ x: event.clientX, y: event.clientY });
618+
setIsTitleEmojiPickerOpen(true);
619+
}, [isLocked, updateTitleSelection]);
620+
551621
const acceptRefinement = () => {
552622
if (refinedContent) {
553623
setContent(refinedContent);
@@ -780,7 +850,20 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
780850
<div className="flex-1 flex flex-col bg-background overflow-y-auto">
781851
<div className="flex justify-between items-center px-4 h-7 gap-4 border-b border-border-color flex-shrink-0 bg-secondary">
782852
<div className="flex items-center gap-3 flex-1 min-w-0">
783-
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Document Title" disabled={isGeneratingTitle} readOnly={isLocked} className={`bg-transparent text-base font-semibold text-text-main focus:outline-none w-full truncate ${isLocked ? 'cursor-default' : ''}`}/>
853+
<input
854+
ref={titleInputRef}
855+
type="text"
856+
value={title}
857+
onChange={(e) => setTitle(e.target.value)}
858+
onContextMenu={handleTitleContextMenu}
859+
onSelect={updateTitleSelection}
860+
onKeyUp={updateTitleSelection}
861+
onMouseUp={updateTitleSelection}
862+
placeholder="Document Title"
863+
disabled={isGeneratingTitle}
864+
readOnly={isLocked}
865+
className={`bg-transparent text-base font-semibold text-text-main focus:outline-none w-full truncate ${isLocked ? 'cursor-default' : ''}`}
866+
/>
784867
{canAddEmojiToTitle && (
785868
<IconButton
786869
onClick={handleAddEmojiToTitle}
@@ -946,6 +1029,13 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
9461029
</form>
9471030
</Modal>
9481031
)}
1032+
<EmojiPickerOverlay
1033+
isOpen={isTitleEmojiPickerOpen}
1034+
anchor={titleEmojiAnchor}
1035+
onClose={closeTitleEmojiPicker}
1036+
onSelectEmoji={handleTitleEmojiSelect}
1037+
ariaLabel="Insert emoji into document title"
1038+
/>
9491039
</div>
9501040
);
9511041
};

esbuild.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const buildOrWatch = async (name, config) => {
4949
entryPoints: ['index.tsx'],
5050
outfile: 'dist/renderer.js',
5151
format: 'esm',
52+
external: ['emoji-picker-react'],
5253
// external: ['uuid', 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'], // REMOVED
5354
}),
5455
]);

tests/mocks/emoji-picker-react.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const EmojiPicker = () => null;
2+
3+
export const Theme = {
4+
DARK: 'dark',
5+
LIGHT: 'light',
6+
} as const;
7+
8+
export default EmojiPicker;

vitest.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import { defineConfig } from 'vitest/config';
22
import react from '@vitejs/plugin-react';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
37

48
export default defineConfig({
59
plugins: [react()],
10+
resolve: {
11+
alias: {
12+
'emoji-picker-react': path.resolve(__dirname, 'tests/mocks/emoji-picker-react.ts'),
13+
},
14+
},
615
test: {
716
environment: 'jsdom',
817
setupFiles: './vitest.setup.ts',

vitest.setup.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
import '@testing-library/jest-dom/vitest';
2+
import { vi } from 'vitest';
3+
4+
vi.mock('emoji-picker-react', () => ({
5+
__esModule: true,
6+
default: () => null,
7+
Theme: {
8+
DARK: 'dark',
9+
LIGHT: 'light',
10+
},
11+
}));
212

313
// Mock matchMedia for components relying on it (e.g. theme detection)
414
if (!window.matchMedia) {

0 commit comments

Comments
 (0)