Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import ThemeEditorConfirmModal from './theme-editor-confirm-modal';
import ThemeEditorInputModal from './theme-editor-input-modal';
import ThemeEditorShortcutsModal from './theme-editor-shortcuts-modal';
import ThemeEditorToolbar from './theme-editor-toolbar';
import ThemeFileTree from './theme-file-tree';
import ThemeInstalledModal from './theme-installed-modal';
Expand Down Expand Up @@ -250,6 +251,13 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => {
const [isSaving, setIsSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [isReviewOpen, setIsReviewOpen] = useState(false);
const [isShortcutsOpen, setIsShortcutsOpen] = useState(false);
// CodeMirror is keyed by file path, so it re-mounts every time the user
// switches files. The default `autoFocus` then steals focus from the tree
// each time arrow keys move selection onto a file — making keyboard nav
// unusable. Gate autoFocus on a one-shot flag: true on first mount and
// when a tree click explicitly opens a file, false otherwise.
const [editorAutoFocus, setEditorAutoFocus] = useState(true);
const [isTextWrapEnabled, setIsTextWrapEnabled] = useState(false);
const [editorExtensions, setEditorExtensions] = useState<Array<ReturnType<typeof search> | typeof oneDark | typeof editorSelectionTheme | typeof EditorView.lineWrapping | Awaited<ReturnType<typeof getLanguageExtension>>>>([]);

Expand Down Expand Up @@ -435,20 +443,35 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => {
const handleSaveRef = useRef<() => void>(() => {});

useEffect(() => {
const isTypingTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return true;
}
return target.isContentEditable;
};

const handleKeydown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
void handleSaveRef.current();
return;
}

if (event.key !== 'Escape') {
if (event.key === '?' && !event.metaKey && !event.ctrlKey && !event.altKey && !isTypingTarget(event.target)) {
event.preventDefault();
setIsShortcutsOpen(true);
return;
}

event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
// Intentionally do not consume Escape here. Sub-dialogs (the
// shortcuts cheat sheet, the review modal, and NiceModal-rendered
// confirm/input modals) each manage their own Esc dismissal, and
// swallowing Esc here would either block them outright or force
// users to press Esc twice. Leaving Esc alone lets each layer
// close itself before the next press can reach the editor frame.
};

window.addEventListener('keydown', handleKeydown, true);
Expand Down Expand Up @@ -876,6 +899,11 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => {
const openFile = (path: string) => {
setSelectedNode({type: 'file', path});
ensurePathExpanded(path);
// Explicit open via click (or review-modal "Open in editor") — focus
// the editor so the user can start typing. Keyboard arrow-key
// navigation in the tree goes through setSelectedNode directly and
// skips this, so focus stays in the tree.
setEditorAutoFocus(true);
};

const selectedFileStatus = selectedFile ? changesMap.get(selectedFile.path) : null;
Expand All @@ -900,6 +928,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => {
isSaving={isSaving}
onClose={closeEditor}
onOpenReview={() => setIsReviewOpen(true)}
onOpenShortcuts={() => setIsShortcutsOpen(true)}
onSave={() => void handleSave()}
/>

Expand Down Expand Up @@ -983,6 +1012,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => {
{selectedFile?.editable && (
<CodeMirror
key={`${selectedFile.path}:${editorExtensions.length}`}
autoFocus={editorAutoFocus}
basicSetup={{
highlightActiveLine: false,
highlightActiveLineGutter: false
Expand All @@ -992,7 +1022,6 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => {
height='full'
theme={oneDark}
value={selectedFile.content || ''}
autoFocus
onChange={(value) => {
setCurrentFiles(files => ({
...files,
Expand All @@ -1002,6 +1031,17 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => {
}
}));
}}
onCreateEditor={() => {
// Consume the one-shot autoFocus flag
// once CodeMirror has mounted. Next
// remount (e.g. arrow-key navigation
// through the tree) will see
// autoFocus=false and won't steal
// focus from the tree.
if (editorAutoFocus) {
setEditorAutoFocus(false);
}
}}
/>
)}
</div>
Expand All @@ -1019,6 +1059,10 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => {
onRevert={handleRevertPath}
/>
)}

{isShortcutsOpen && (
<ThemeEditorShortcutsModal onClose={() => setIsShortcutsOpen(false)} />
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, {useEffect, useRef} from 'react';
import {X} from 'lucide-react';
import {iconButtonClass} from './theme-editor-styles';

type Shortcut = {
keys: string[];
description: string;
};

type ShortcutSection = {
title: string;
shortcuts: Shortcut[];
};

const SHORTCUT_SECTIONS: ShortcutSection[] = [
{
title: 'Global',
shortcuts: [
{keys: ['⌘/Ctrl', 'S'], description: 'Save and upload theme'},
{keys: ['Esc'], description: 'Close editor (prompts if unsaved)'},
{keys: ['?'], description: 'Show keyboard shortcuts'}
]
},
{
title: 'File tree',
shortcuts: [
{keys: ['↑', '↓'], description: 'Move selection'},
{keys: ['→'], description: 'Expand folder or move to first child'},
{keys: ['←'], description: 'Collapse folder or move to parent'},
{keys: ['Enter'], description: 'Open file or toggle folder'},
{keys: ['Space'], description: 'Open file or toggle folder'},
{keys: ['F2'], description: 'Rename selected file or folder'},
{keys: ['Del'], description: 'Delete selected file or folder'}
]
}
];

const kbdClass = 'inline-flex min-w-[26px] items-center justify-center rounded border border-[#2f333b] bg-[#1a1d21] px-1.5 py-0.5 text-[11px] font-medium text-[#e6e7ea]';

type Props = {
onClose: () => void;
};

const ThemeEditorShortcutsModal: React.FC<Props> = ({onClose}) => {
const closeButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
closeButtonRef.current?.focus();
}, []);

const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
// Element-level handler so the editor's own window-capture Esc
// listener doesn't swallow this. Stop propagation to keep the
// dialog dismissable without also triggering the editor's
// close-with-discard flow.
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
}
};

return (
<div
aria-label='Keyboard shortcuts'
aria-modal='true'
className='absolute inset-0 z-20 flex items-center justify-center bg-[rgba(8,10,14,0.64)]'
role='dialog'
onClick={onClose}
onKeyDown={onKeyDown}
>
<div
className='flex max-h-[min(80vh,640px)] w-[min(560px,calc(100%-24px))] flex-col overflow-hidden rounded-[10px] border border-[#2b3038] bg-[#171a20] p-5 shadow-[0_24px_64px_rgba(0,0,0,0.45)]'
data-testid='theme-editor-shortcuts-modal'
onClick={event => event.stopPropagation()}
>
<div className='mb-3 flex items-center justify-between gap-3'>
<h3 className='text-[16px] font-semibold text-[#f4f5f7]'>Keyboard shortcuts</h3>
<button ref={closeButtonRef} aria-label='Close keyboard shortcuts' className={iconButtonClass} type='button' onClick={onClose}>
<X size={14} />
</button>
</div>
<div className='min-h-0 flex-1 space-y-4 overflow-y-auto pr-1'>
{SHORTCUT_SECTIONS.map(section => (
<section key={section.title}>
<h4 className='mb-2 text-[11px] font-semibold tracking-[0.08em] text-[#8a8f98] uppercase'>{section.title}</h4>
<ul className='space-y-1.5'>
{section.shortcuts.map(shortcut => (
<li key={shortcut.description} className='flex items-center justify-between gap-4 rounded-md border border-transparent px-2 py-1 text-[13px] text-[#d4d8de]'>
<span>{shortcut.description}</span>
<span className='flex shrink-0 items-center gap-1'>
{shortcut.keys.map((key, index) => (
<React.Fragment key={key}>
{index > 0 && <span className='text-[11px] text-[#6a6f78]'>+</span>}
<kbd className={kbdClass}>{key}</kbd>
</React.Fragment>
))}
</span>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</div>
);
};

export default ThemeEditorShortcutsModal;
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React from 'react';
import {Save, X} from 'lucide-react';
import {ghostButtonClass, primaryButtonClass} from './theme-editor-styles';
import {Keyboard, Save, X} from 'lucide-react';
import {ghostButtonClass, iconButtonClass, primaryButtonClass} from './theme-editor-styles';
import type {ThemeChange} from './theme-editor-utils';

type ThemeEditorToolbarProps = {
currentThemeName: string;
changes: ThemeChange[];
isSaving: boolean;
onOpenReview: () => void;
onOpenShortcuts: () => void;
onClose: () => void;
onSave: () => void;
};
Expand All @@ -17,6 +18,7 @@ const ThemeEditorToolbar: React.FC<ThemeEditorToolbarProps> = ({
changes,
isSaving,
onOpenReview,
onOpenShortcuts,
onClose,
onSave
}) => {
Expand All @@ -36,6 +38,9 @@ const ThemeEditorToolbar: React.FC<ThemeEditorToolbarProps> = ({
</button>
)}
<div className='grow' />
<button aria-label='Show keyboard shortcuts' className={iconButtonClass} title='Keyboard shortcuts (?)' type='button' onClick={onOpenShortcuts}>
<Keyboard size={14} />
</button>
<button className={ghostButtonClass} type='button' onClick={onClose}>
<X size={14} />
Close
Expand Down
Loading
Loading