Skip to content

Commit a9530c5

Browse files
authored
Merge pull request #217 from beNative/codex/add-zoom-control-to-code-editor
Scope workspace zoom to focused view
2 parents 0de84cd + c0d113b commit a9530c5

8 files changed

Lines changed: 180 additions & 45 deletions

File tree

App.tsx

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const MIN_LOGGER_HEIGHT = 100;
5151
const PREVIEW_INITIAL_SCALE = 1;
5252
const PREVIEW_MIN_SCALE = 0.25;
5353
const PREVIEW_MAX_SCALE = 5;
54-
const PREVIEW_ZOOM_STEP = 0.25;
54+
const PREVIEW_ZOOM_STEP = 0.05;
5555

5656
const isElectron = !!window.electronAPI;
5757

@@ -231,10 +231,12 @@ export const MainApp: React.FC = () => {
231231
const [isRestoringActiveDocument, setIsRestoringActiveDocument] = useState(true);
232232
const [hasRestoredActiveDocument, setHasRestoredActiveDocument] = useState(false);
233233
const [previewScale, setPreviewScale] = useState(PREVIEW_INITIAL_SCALE);
234+
const [editorScale, setEditorScale] = useState(PREVIEW_INITIAL_SCALE);
234235
const [previewResetSignal, setPreviewResetSignal] = useState(0);
235236
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
236237
const [isPreviewZoomReady, setIsPreviewZoomReady] = useState(false);
237238
const [previewMetadata, setPreviewMetadata] = useState<PreviewMetadata | null>(null);
239+
const [workspaceZoomTarget, setWorkspaceZoomTarget] = useState<'preview' | 'editor'>('editor');
238240

239241
useEffect(() => {
240242
if (!isPreviewVisible) {
@@ -252,25 +254,41 @@ export const MainApp: React.FC = () => {
252254
const openDocumentIds = tabState.order;
253255
const storedActiveDocumentIdRef = useRef<string | null>(null);
254256

255-
const clampPreviewScale = useCallback((value: number) => {
257+
const clampZoomScale = useCallback((value: number) => {
256258
return Math.min(Math.max(value, PREVIEW_MIN_SCALE), PREVIEW_MAX_SCALE);
257259
}, []);
258260

259261
const handlePreviewScaleChange = useCallback((value: number) => {
260-
setPreviewScale(clampPreviewScale(value));
261-
}, [clampPreviewScale]);
262+
setPreviewScale(clampZoomScale(value));
263+
}, [clampZoomScale]);
262264

263265
const handlePreviewZoomIn = useCallback(() => {
264-
setPreviewScale(prev => clampPreviewScale(prev * (1 + PREVIEW_ZOOM_STEP)));
265-
}, [clampPreviewScale]);
266+
if (workspaceZoomTarget === 'preview') {
267+
setPreviewScale(prev => clampZoomScale(prev + PREVIEW_ZOOM_STEP));
268+
} else {
269+
setEditorScale(prev => clampZoomScale(prev + PREVIEW_ZOOM_STEP));
270+
}
271+
}, [clampZoomScale, workspaceZoomTarget]);
266272

267273
const handlePreviewZoomOut = useCallback(() => {
268-
setPreviewScale(prev => clampPreviewScale(prev / (1 + PREVIEW_ZOOM_STEP)));
269-
}, [clampPreviewScale]);
274+
if (workspaceZoomTarget === 'preview') {
275+
setPreviewScale(prev => clampZoomScale(prev - PREVIEW_ZOOM_STEP));
276+
} else {
277+
setEditorScale(prev => clampZoomScale(prev - PREVIEW_ZOOM_STEP));
278+
}
279+
}, [clampZoomScale, workspaceZoomTarget]);
270280

271281
const handlePreviewReset = useCallback(() => {
272-
setPreviewScale(PREVIEW_INITIAL_SCALE);
273-
setPreviewResetSignal(prev => prev + 1);
282+
if (workspaceZoomTarget === 'preview') {
283+
setPreviewScale(PREVIEW_INITIAL_SCALE);
284+
setPreviewResetSignal(prev => prev + 1);
285+
} else {
286+
setEditorScale(PREVIEW_INITIAL_SCALE);
287+
}
288+
}, [workspaceZoomTarget]);
289+
290+
const handleWorkspaceZoomTargetChange = useCallback((target: 'preview' | 'editor') => {
291+
setWorkspaceZoomTarget(target);
274292
}, []);
275293

276294
const activateDocumentTab = useCallback((documentId: string) => {
@@ -483,11 +501,39 @@ export const MainApp: React.FC = () => {
483501
}
484502
}, [activeDocument]);
485503

504+
const previewZoomAvailable = useMemo(() => {
505+
if (view === 'info') {
506+
return true;
507+
}
508+
return isPreviewVisible && isPreviewZoomReady;
509+
}, [isPreviewVisible, isPreviewZoomReady, view]);
510+
511+
const editorZoomAvailable = useMemo(() => {
512+
return view === 'editor' && documentView === 'editor' && Boolean(activeDocument);
513+
}, [activeDocument, documentView, view]);
514+
515+
useEffect(() => {
516+
if (workspaceZoomTarget === 'preview' && !previewZoomAvailable && editorZoomAvailable) {
517+
setWorkspaceZoomTarget('editor');
518+
} else if (workspaceZoomTarget === 'editor' && !editorZoomAvailable && previewZoomAvailable) {
519+
setWorkspaceZoomTarget('preview');
520+
}
521+
}, [editorZoomAvailable, previewZoomAvailable, workspaceZoomTarget]);
522+
523+
const isWorkspaceZoomAvailable = useMemo(() => {
524+
return workspaceZoomTarget === 'preview' ? previewZoomAvailable : editorZoomAvailable;
525+
}, [editorZoomAvailable, previewZoomAvailable, workspaceZoomTarget]);
526+
527+
const workspaceZoomScale = useMemo(() => {
528+
return workspaceZoomTarget === 'preview' ? previewScale : editorScale;
529+
}, [editorScale, previewScale, workspaceZoomTarget]);
530+
486531
const documentItems = useMemo(() => items.filter(item => item.type === 'document'), [items]);
487532
const activeDocumentId = activeDocument?.id ?? null;
488533

489534
useEffect(() => {
490535
setPreviewScale(PREVIEW_INITIAL_SCALE);
536+
setEditorScale(PREVIEW_INITIAL_SCALE);
491537
setPreviewResetSignal(prev => prev + 1);
492538
}, [activeNode?.id, activeNode?.type]);
493539

@@ -2802,7 +2848,7 @@ export const MainApp: React.FC = () => {
28022848
return (
28032849
<InfoView
28042850
settings={settings}
2805-
previewScale={previewScale}
2851+
previewScale={workspaceZoomScale}
28062852
onPreviewScaleChange={handlePreviewScaleChange}
28072853
previewZoomOptions={{
28082854
minScale: PREVIEW_MIN_SCALE,
@@ -2813,6 +2859,7 @@ export const MainApp: React.FC = () => {
28132859
previewResetSignal={previewResetSignal}
28142860
onPreviewVisibilityChange={setIsPreviewVisible}
28152861
onPreviewZoomAvailabilityChange={setIsPreviewZoomReady}
2862+
onZoomTargetChange={handleWorkspaceZoomTargetChange}
28162863
/>
28172864
);
28182865
}
@@ -2852,6 +2899,7 @@ export const MainApp: React.FC = () => {
28522899
onToggleLock={(locked) => handleSetNodeLockState(activeNode.id, locked)}
28532900
formatTrigger={formatTrigger}
28542901
previewScale={previewScale}
2902+
editorScale={editorScale}
28552903
onPreviewScaleChange={handlePreviewScaleChange}
28562904
previewMinScale={PREVIEW_MIN_SCALE}
28572905
previewMaxScale={PREVIEW_MAX_SCALE}
@@ -2861,6 +2909,7 @@ export const MainApp: React.FC = () => {
28612909
onPreviewVisibilityChange={setIsPreviewVisible}
28622910
onPreviewZoomAvailabilityChange={setIsPreviewZoomReady}
28632911
onPreviewMetadataChange={setPreviewMetadata}
2912+
onZoomTargetChange={handleWorkspaceZoomTargetChange}
28642913
/>
28652914
);
28662915
}
@@ -3045,15 +3094,16 @@ export const MainApp: React.FC = () => {
30453094
databaseStatus={databaseStatus}
30463095
onDatabaseMenu={handleDatabaseMenu}
30473096
onOpenAbout={handleOpenAbout}
3048-
previewScale={previewScale}
3097+
previewScale={workspaceZoomScale}
30493098
onPreviewZoomIn={handlePreviewZoomIn}
30503099
onPreviewZoomOut={handlePreviewZoomOut}
30513100
onPreviewReset={handlePreviewReset}
3052-
isPreviewZoomAvailable={isPreviewVisible && isPreviewZoomReady}
3101+
isPreviewZoomAvailable={isWorkspaceZoomAvailable}
30533102
previewMinScale={PREVIEW_MIN_SCALE}
30543103
previewMaxScale={PREVIEW_MAX_SCALE}
30553104
previewInitialScale={PREVIEW_INITIAL_SCALE}
30563105
previewMetadata={previewMetadata}
3106+
zoomTarget={workspaceZoomTarget}
30573107
/>
30583108
</div>
30593109

components/CodeEditor.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface CodeEditorProps {
2121
activeLineHighlightColorLight?: string;
2222
activeLineHighlightColorDark?: string;
2323
readOnly?: boolean;
24+
onFocusChange?: (hasFocus: boolean) => void;
2425
}
2526

2627
export interface CodeEditorHandle {
@@ -118,14 +119,16 @@ const toMonacoKeybinding = (monacoApi: any, keys: string[]): number | null => {
118119
return keybinding | primaryKey;
119120
};
120121

121-
const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, language, onChange, onScroll, customShortcuts = {}, fontFamily, fontSize, activeLineHighlightColorLight, activeLineHighlightColorDark, readOnly = false }, ref) => {
122+
const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, language, onChange, onScroll, customShortcuts = {}, fontFamily, fontSize, activeLineHighlightColorLight, activeLineHighlightColorDark, readOnly = false, onFocusChange }, ref) => {
122123
const editorRef = useRef<HTMLDivElement>(null);
123124
const monacoInstanceRef = useRef<any>(null);
124125
const monacoApiRef = useRef<any>(null);
125126
const { theme } = useTheme();
126127
const contentRef = useRef(content);
127128
const customShortcutsRef = useRef<Record<string, string[]>>({});
128129
const actionDisposablesRef = useRef<Array<{ dispose: () => void }>>([]);
130+
const focusDisposableRef = useRef<{ dispose: () => void } | null>(null);
131+
const blurDisposableRef = useRef<{ dispose: () => void } | null>(null);
129132
const computedFontFamily = useMemo(() => {
130133
const candidate = (fontFamily ?? '').trim();
131134
return candidate || DEFAULT_SETTINGS.editorFontFamily;
@@ -202,6 +205,13 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
202205
actionDisposablesRef.current = [];
203206
}, []);
204207

208+
const disposeFocusListeners = useCallback(() => {
209+
focusDisposableRef.current?.dispose();
210+
focusDisposableRef.current = null;
211+
blurDisposableRef.current?.dispose();
212+
blurDisposableRef.current = null;
213+
}, []);
214+
205215
const applyEditorShortcuts = useCallback(() => {
206216
const monacoApi = monacoApiRef.current;
207217
if (!monacoInstanceRef.current || !monacoApi) {
@@ -267,6 +277,7 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
267277

268278
if (monacoInstanceRef.current) {
269279
disposeEditorShortcuts();
280+
disposeFocusListeners();
270281
monacoInstanceRef.current.dispose();
271282
}
272283

@@ -309,6 +320,16 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
309320
}
310321
});
311322

323+
disposeFocusListeners();
324+
if (onFocusChange) {
325+
focusDisposableRef.current = editorInstance.onDidFocusEditorWidget(() => {
326+
onFocusChange(true);
327+
});
328+
blurDisposableRef.current = editorInstance.onDidBlurEditorWidget(() => {
329+
onFocusChange(false);
330+
});
331+
}
332+
312333
monacoInstanceRef.current = editorInstance;
313334
applyEditorShortcuts();
314335
} catch (error) {
@@ -322,13 +343,14 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
322343
return () => {
323344
isCancelled = true;
324345
disposeEditorShortcuts();
346+
disposeFocusListeners();
325347
if (monacoInstanceRef.current) {
326348
monacoInstanceRef.current.dispose();
327349
monacoInstanceRef.current = null;
328350
}
329351
monacoApiRef.current = null;
330352
};
331-
}, [onChange, onScroll, applyEditorShortcuts, disposeEditorShortcuts, computedFontFamily, computedFontSize, readOnly]);
353+
}, [onChange, onScroll, applyEditorShortcuts, disposeEditorShortcuts, disposeFocusListeners, computedFontFamily, computedFontSize, readOnly, onFocusChange]);
332354

333355
// Effect to update content from props if it changes externally
334356
useEffect(() => {

components/InfoView.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface InfoViewProps {
2626
previewResetSignal?: number;
2727
onPreviewVisibilityChange?: (isVisible: boolean) => void;
2828
onPreviewZoomAvailabilityChange?: (isAvailable: boolean) => void;
29+
onZoomTargetChange?: (target: 'preview' | 'editor') => void;
2930
}
3031

3132
const PreviewZoomAvailabilityReset: React.FC<{
@@ -46,6 +47,7 @@ const InfoView: React.FC<InfoViewProps> = ({
4647
previewResetSignal,
4748
onPreviewVisibilityChange,
4849
onPreviewZoomAvailabilityChange,
50+
onZoomTargetChange,
4951
}) => {
5052
const [activeTab, setActiveTab] = useState<DocTab>('Readme');
5153
const [documents, setDocuments] = useState<Record<DocTab, string>>({
@@ -109,11 +111,12 @@ const InfoView: React.FC<InfoViewProps> = ({
109111

110112
useEffect(() => {
111113
onPreviewVisibilityChange?.(true);
114+
onZoomTargetChange?.('preview');
112115
return () => {
113116
onPreviewVisibilityChange?.(false);
114117
onPreviewZoomAvailabilityChange?.(false);
115118
};
116-
}, [onPreviewVisibilityChange, onPreviewZoomAvailabilityChange]);
119+
}, [onPreviewVisibilityChange, onPreviewZoomAvailabilityChange, onZoomTargetChange]);
117120

118121
const activeTabContent = documents[activeTab];
119122
const isLoadingActiveTab = activeTabContent === 'Loading...';

components/MonacoDiffEditor.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@ interface MonacoDiffEditorProps {
1919
fontSize?: number;
2020
activeLineHighlightColorLight?: string;
2121
activeLineHighlightColorDark?: string;
22+
onFocusChange?: (hasFocus: boolean) => void;
2223
}
2324

24-
const MonacoDiffEditor: React.FC<MonacoDiffEditorProps> = ({ oldText, newText, language, renderMode = 'side-by-side', readOnly = false, onChange, onScroll, fontFamily, fontSize, activeLineHighlightColorLight, activeLineHighlightColorDark }) => {
25+
const MonacoDiffEditor: React.FC<MonacoDiffEditorProps> = ({ oldText, newText, language, renderMode = 'side-by-side', readOnly = false, onChange, onScroll, fontFamily, fontSize, activeLineHighlightColorLight, activeLineHighlightColorDark, onFocusChange }) => {
2526
const editorRef = useRef<HTMLDivElement>(null);
2627
const editorInstanceRef = useRef<any>(null);
2728
const monacoApiRef = useRef<any>(null);
2829
const { theme } = useTheme();
2930
const modelsRef = useRef<{ original: any; modified: any } | null>(null);
3031
const changeListenerRef = useRef<{ dispose: () => void } | null>(null);
3132
const scrollListenerRef = useRef<{ dispose: () => void } | null>(null);
33+
const focusListenerRef = useRef<{ dispose: () => void } | null>(null);
34+
const blurListenerRef = useRef<{ dispose: () => void } | null>(null);
3235
const computedFontFamily = useMemo(() => {
3336
const candidate = (fontFamily ?? '').trim();
3437
return candidate || DEFAULT_SETTINGS.editorFontFamily;
@@ -73,6 +76,10 @@ const MonacoDiffEditor: React.FC<MonacoDiffEditorProps> = ({ oldText, newText, l
7376
scrollListenerRef.current.dispose();
7477
scrollListenerRef.current = null;
7578
}
79+
focusListenerRef.current?.dispose();
80+
focusListenerRef.current = null;
81+
blurListenerRef.current?.dispose();
82+
blurListenerRef.current = null;
7683
}, []);
7784

7885
useEffect(() => {
@@ -153,6 +160,15 @@ const MonacoDiffEditor: React.FC<MonacoDiffEditorProps> = ({ oldText, newText, l
153160
});
154161
});
155162
}
163+
164+
if (onFocusChange) {
165+
focusListenerRef.current = modifiedEditor.onDidFocusEditorWidget(() => {
166+
onFocusChange(true);
167+
});
168+
blurListenerRef.current = modifiedEditor.onDidBlurEditorWidget(() => {
169+
onFocusChange(false);
170+
});
171+
}
156172
} catch (error) {
157173
// eslint-disable-next-line no-console
158174
console.error('Failed to initialize Monaco diff editor', error);

0 commit comments

Comments
 (0)