Skip to content

Commit 7c5a338

Browse files
drakehanguyenDrakeNguyen
andauthored
Feature/code panel flex layout diff alignment (#74)
* 0.1.9 * feat(diff-checker): enhance diff alignment and editor functionality - Updated DiffChecker component to improve text wrapping options, setting both original and modified wrap text to false by default. - Introduced new state variables for zoom epoch and alignment zones to enhance visual accuracy between editors. - Added logic to conditionally render alignment zones based on content presence in both editors, preventing phantom rows. - Enhanced view zone management to ensure accurate height calculations for wrapped lines, improving the overall user experience. This update aims to provide a more precise and user-friendly diff comparison experience. * feat: enhance tool components with flexible layout adjustments - Updated multiple tool components to improve layout by adding flex properties and ensuring panels can grow and shrink with their containers. - Introduced `fillHeight` prop to `CodePanel` for better height management, allowing for a more responsive design. - Adjusted body sections of tools like BaseEncoder, CidrAnalyzer, and others to utilize flexbox for improved usability and visual consistency. These changes aim to enhance the user experience by providing a more adaptable interface across various tools. * fix: add overflow-y-auto to tool components for improved scrolling - Updated multiple tool components to include `overflow-y-auto` in the body section, enhancing usability by allowing for better scrolling behavior when content exceeds the visible area. - This change ensures that users can access all content without layout issues, contributing to a more user-friendly interface across tools. These adjustments aim to improve the overall experience when interacting with various tools in the application. * fix(monaco-utils): add TypeScript ignore comment for Monaco options - Added a TypeScript ignore comment to the `renderValidationDecorations` option in the `createMonacoOptions` function to accommodate a valid Monaco runtime option that is not yet reflected in the bundled type definitions. - This change ensures that the editor configuration remains functional while addressing type definition discrepancies. This update aims to improve the integration of Monaco editor options within the application. --------- Co-authored-by: DrakeNguyen <drake.ha.nguyen@gmail.com>
1 parent 28f12f4 commit 7c5a338

32 files changed

+272
-177
lines changed

src/components/tools/BaseEncoder.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import { useToolState } from '@/components/providers/ToolStateProvider';
44
import { Button } from '@/components/ui/button';
55
import { CodePanel } from '@/components/ui/code-panel';
66
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
7-
import { LabeledInput } from '@/components/ui/labeled-input';
87
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
9-
import { DEFAULT_BASE_OPTIONS, BASE_ENCODING_TYPES, BASE_EXAMPLES, BASE64_VARIANTS, LINE_WRAP_OPTIONS, HEX_CASE_OPTIONS } from '@/config/base-encoder-config';
8+
import { BASE64_VARIANTS, BASE_ENCODING_TYPES, BASE_EXAMPLES, DEFAULT_BASE_OPTIONS, HEX_CASE_OPTIONS, LINE_WRAP_OPTIONS } from '@/config/base-encoder-config';
109
import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme';
11-
import { encodeBase, decodeBase, type BaseEncoderOptions, type BaseEncoderResult } from '@/libs/base-encoder';
10+
import { decodeBase, encodeBase, type BaseEncoderOptions, type BaseEncoderResult } from '@/libs/base-encoder';
1211
import { cn } from '@/libs/utils';
1312
import { ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
1413
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -173,8 +172,8 @@ export function BaseEncoder({ className, instanceId }: BaseEncoderProps) {
173172
</div>
174173

175174
{/* Body Section */}
176-
<div className="flex-1 bg-background px-[24px] pt-6 pb-10">
177-
<div className="flex flex-col gap-4">
175+
<div className="flex-1 flex flex-col bg-background px-[24px] pt-6 pb-10 min-h-0 overflow-y-auto">
176+
<div className="flex-1 flex flex-col gap-4 min-h-0">
178177
{/* Controls */}
179178
<div className="flex flex-col gap-4">
180179
<div className="flex items-center gap-3 flex-wrap">
@@ -281,9 +280,9 @@ export function BaseEncoder({ className, instanceId }: BaseEncoderProps) {
281280
</div>
282281

283282
{/* Side-by-side Editor Panels */}
284-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
283+
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-0">
285284
{/* Input Panel */}
286-
<CodePanel
285+
<CodePanel fillHeight={true}
287286
tabs={inputTabs}
288287
activeTab={mode}
289288
onTabChange={handleModeChange}
@@ -354,7 +353,7 @@ export function BaseEncoder({ className, instanceId }: BaseEncoderProps) {
354353
/>
355354

356355
{/* Output Panel */}
357-
<CodePanel
356+
<CodePanel fillHeight={true}
358357
title="Output"
359358
value={output}
360359
language="plaintext"

src/components/tools/CidrAnalyzer.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ export function CidrAnalyzer({ className, instanceId }: CidrAnalyzerProps) {
209209
</div>
210210

211211
{/* Body Section */}
212-
<div className="flex-1 bg-background px-[24px] pt-6 pb-10">
213-
<div className="flex flex-col gap-4">
212+
<div className="flex-1 flex flex-col bg-background px-[24px] pt-6 pb-10 min-h-0 overflow-y-auto">
213+
<div className="flex-1 flex flex-col gap-4 min-h-0">
214214
{/* Controls */}
215215
<div className="flex flex-col gap-4">
216216
{/* Input Row */}
@@ -301,11 +301,12 @@ export function CidrAnalyzer({ className, instanceId }: CidrAnalyzerProps) {
301301
</div>
302302

303303
{/* Output Panel */}
304-
<CodePanel
304+
<CodePanel fillHeight={true}
305305
title="Analysis Result"
306306
value={output}
307307
language="plaintext"
308308
height="500px"
309+
className="flex-1"
309310
theme={theme}
310311
wrapText={wrapText}
311312
onWrapTextChange={setWrapText}

src/components/tools/CronParser.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,8 @@ export function CronParser({ className, instanceId }: CronParserProps) {
252252
</div>
253253

254254
{/* Body Section */}
255-
<div className="flex-1 bg-background px-[24px] pt-6 pb-10 overflow-y-auto">
256-
<div className="flex flex-col gap-6">
255+
<div className="flex-1 flex flex-col bg-background px-[24px] pt-6 pb-10 min-h-0 overflow-y-auto">
256+
<div className="flex-1 flex flex-col gap-6 min-h-0">
257257
{/* Controls */}
258258
<div className="flex flex-col gap-4">
259259
{/* Expression Display */}
@@ -405,11 +405,12 @@ export function CronParser({ className, instanceId }: CronParserProps) {
405405
</div>
406406

407407
{/* Preview Panel */}
408-
<CodePanel
408+
<CodePanel fillHeight={true}
409409
title="Preview"
410410
value={preview || (isValidating ? 'Validating...' : '// Build your cron expression above to see preview')}
411411
language="plaintext"
412412
height="400px"
413+
className="flex-1"
413414
theme={theme}
414415
wrapText={wrapText}
415416
onWrapTextChange={setWrapText}

src/components/tools/DataFormatConverter.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,8 @@ export function DataFormatConverter({ className, instanceId }: DataFormatConvert
203203
</div>
204204

205205
{/* Body Section */}
206-
<div className="flex-1 bg-background px-[24px] pt-6 pb-10">
207-
<div className="flex flex-col gap-4">
206+
<div className="flex-1 flex flex-col bg-background px-[24px] pt-6 pb-10 min-h-0 overflow-y-auto">
207+
<div className="flex-1 flex flex-col gap-4 min-h-0">
208208
{/* Controls */}
209209
<div className="flex flex-col gap-4">
210210
{/* Main Controls Row */}
@@ -290,9 +290,9 @@ export function DataFormatConverter({ className, instanceId }: DataFormatConvert
290290
</div>
291291

292292
{/* Side-by-side Editor Panels */}
293-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
293+
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-0">
294294
{/* Input Panel */}
295-
<CodePanel
295+
<CodePanel fillHeight={true}
296296
title={`Input (${options.inputFormat.toUpperCase()})`}
297297
value={input}
298298
onChange={setInput}
@@ -351,7 +351,7 @@ export function DataFormatConverter({ className, instanceId }: DataFormatConvert
351351
/>
352352

353353
{/* Output Panel */}
354-
<CodePanel
354+
<CodePanel fillHeight={true}
355355
title={`Output (${options.outputFormat.toUpperCase()})`}
356356
value={output}
357357
language={getOutputLanguage()}

src/components/tools/DiffChecker.tsx

Lines changed: 107 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
6060
const [options, setOptions] = useState<DiffOptions>(DEFAULT_DIFF_OPTIONS);
6161
const [stats, setStats] = useState<DiffStats>({ additions: 0, deletions: 0, changes: 0 });
6262
const [isHydrated, setIsHydrated] = useState(false);
63-
const [originalWrapText, setOriginalWrapText] = useState(true);
64-
const [modifiedWrapText, setModifiedWrapText] = useState(true);
63+
const [originalWrapText, setOriginalWrapText] = useState(false);
64+
const [modifiedWrapText, setModifiedWrapText] = useState(false);
6565
const [editorsReady, setEditorsReady] = useState(false);
66+
const [zoomEpoch, setZoomEpoch] = useState(0);
6667

6768
// Editor refs for decorations
6869
const originalEditorRef = useRef<any>(null);
@@ -118,6 +119,9 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
118119
interface ViewZoneData {
119120
afterLineNumber: number;
120121
heightInLines: number;
122+
// Lines in the OTHER editor that this gap is aligning with (for pixel-accurate sizing)
123+
otherEditorStart: number;
124+
otherEditorCount: number;
121125
}
122126

123127
// Calculate diff and apply decorations with character-level highlighting
@@ -154,7 +158,7 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
154158
});
155159
});
156160
// Pad original side so unchanged lines below stay aligned
157-
originalViewZones.push({ afterLineNumber: originalLine - 1, heightInLines: lineCount });
161+
originalViewZones.push({ afterLineNumber: originalLine - 1, heightInLines: lineCount, otherEditorStart: modifiedLine, otherEditorCount: lineCount });
158162
modifiedLine += lineCount;
159163
} else if (change.removed) {
160164
// Check if next is added (paired change for inline diff)
@@ -231,11 +235,15 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
231235
modifiedViewZones.push({
232236
afterLineNumber: modifiedLine + addedLines.length - 1,
233237
heightInLines: removedLines.length - addedLines.length,
238+
otherEditorStart: originalLine + addedLines.length,
239+
otherEditorCount: removedLines.length - addedLines.length,
234240
});
235241
} else if (addedLines.length > removedLines.length) {
236242
originalViewZones.push({
237243
afterLineNumber: originalLine + removedLines.length - 1,
238244
heightInLines: addedLines.length - removedLines.length,
245+
otherEditorStart: modifiedLine + removedLines.length,
246+
otherEditorCount: addedLines.length - removedLines.length,
239247
});
240248
}
241249

@@ -249,7 +257,7 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
249257
type: 'removed',
250258
});
251259
});
252-
modifiedViewZones.push({ afterLineNumber: modifiedLine - 1, heightInLines: lineCount });
260+
modifiedViewZones.push({ afterLineNumber: modifiedLine - 1, heightInLines: lineCount, otherEditorStart: originalLine, otherEditorCount: lineCount });
253261
}
254262
originalLine += lineCount;
255263
} else {
@@ -270,6 +278,10 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
270278
const monaco = (window as any).monaco;
271279
if (!monaco) return;
272280

281+
// Only insert alignment zones when both panels have content — avoids blocking
282+
// the cursor in the empty editor with phantom rows at afterLineNumber: 0.
283+
const shouldAlign = originalText.trim().length > 0 && modifiedText.trim().length > 0;
284+
273285
if (originalEditorRef.current) {
274286
const editor = originalEditorRef.current;
275287
const decorations = [
@@ -280,6 +292,10 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
280292
isWholeLine: true,
281293
className: 'diff-line-removed',
282294
glyphMarginClassName: 'diff-glyph-removed',
295+
overviewRuler: {
296+
color: 'rgba(239, 68, 68, 0.8)',
297+
position: monaco.editor.OverviewRulerLane.Full,
298+
},
283299
},
284300
})),
285301
// Inline character decorations
@@ -295,21 +311,6 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
295311
decorations
296312
);
297313

298-
// Apply alignment view zones
299-
editor.changeViewZones((accessor: any) => {
300-
originalViewZoneIdsRef.current.forEach((id) => accessor.removeZone(id));
301-
originalViewZoneIdsRef.current = [];
302-
originalViewZones.forEach((zone) => {
303-
const domNode = document.createElement('div');
304-
domNode.className = 'diff-placeholder-zone';
305-
const id = accessor.addZone({
306-
afterLineNumber: zone.afterLineNumber,
307-
heightInLines: zone.heightInLines,
308-
domNode,
309-
});
310-
originalViewZoneIdsRef.current.push(id);
311-
});
312-
});
313314
}
314315

315316
if (modifiedEditorRef.current) {
@@ -322,6 +323,10 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
322323
isWholeLine: true,
323324
className: 'diff-line-added',
324325
glyphMarginClassName: 'diff-glyph-added',
326+
overviewRuler: {
327+
color: 'rgba(34, 197, 94, 0.8)',
328+
position: monaco.editor.OverviewRulerLane.Full,
329+
},
325330
},
326331
})),
327332
// Inline character decorations
@@ -336,31 +341,77 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
336341
modifiedDecorationsRef.current,
337342
decorations
338343
);
344+
}
345+
346+
if (!shouldAlign) return;
339347

340-
// Apply alignment view zones
341-
editor.changeViewZones((accessor: any) => {
348+
// Phase 1 — remove stale zones from both editors so getTopForLineNumber
349+
// returns accurate positions unaffected by previously applied zones.
350+
if (originalEditorRef.current) {
351+
originalEditorRef.current.changeViewZones((accessor: any) => {
352+
originalViewZoneIdsRef.current.forEach((id) => accessor.removeZone(id));
353+
originalViewZoneIdsRef.current = [];
354+
});
355+
}
356+
if (modifiedEditorRef.current) {
357+
modifiedEditorRef.current.changeViewZones((accessor: any) => {
342358
modifiedViewZoneIdsRef.current.forEach((id) => accessor.removeZone(id));
343359
modifiedViewZoneIdsRef.current = [];
360+
});
361+
}
362+
363+
// Phase 2 — add new zones. When either panel has word wrap on, derive heightInPx
364+
// from getTopForLineNumber on the other editor so wrapped visual rows are accounted
365+
// for. Fall back to heightInLines when wrap is off so Monaco auto-scales with zoom.
366+
const usePixelZones = originalWrapText || modifiedWrapText;
367+
368+
if (originalEditorRef.current) {
369+
originalEditorRef.current.changeViewZones((accessor: any) => {
370+
originalViewZones.forEach((zone) => {
371+
const domNode = document.createElement('div');
372+
domNode.className = 'diff-placeholder-zone';
373+
let zoneDef: Record<string, unknown> = { afterLineNumber: zone.afterLineNumber, domNode };
374+
if (usePixelZones && modifiedEditorRef.current) {
375+
const topStart = modifiedEditorRef.current.getTopForLineNumber(zone.otherEditorStart);
376+
const topEnd = modifiedEditorRef.current.getTopForLineNumber(zone.otherEditorStart + zone.otherEditorCount);
377+
const heightInPx = topEnd - topStart;
378+
zoneDef = heightInPx > 0 ? { ...zoneDef, heightInPx } : { ...zoneDef, heightInLines: zone.heightInLines };
379+
} else {
380+
zoneDef.heightInLines = zone.heightInLines;
381+
}
382+
const id = accessor.addZone(zoneDef);
383+
originalViewZoneIdsRef.current.push(id);
384+
});
385+
});
386+
}
387+
388+
if (modifiedEditorRef.current) {
389+
modifiedEditorRef.current.changeViewZones((accessor: any) => {
344390
modifiedViewZones.forEach((zone) => {
345391
const domNode = document.createElement('div');
346392
domNode.className = 'diff-placeholder-zone';
347-
const id = accessor.addZone({
348-
afterLineNumber: zone.afterLineNumber,
349-
heightInLines: zone.heightInLines,
350-
domNode,
351-
});
393+
let zoneDef: Record<string, unknown> = { afterLineNumber: zone.afterLineNumber, domNode };
394+
if (usePixelZones && originalEditorRef.current) {
395+
const topStart = originalEditorRef.current.getTopForLineNumber(zone.otherEditorStart);
396+
const topEnd = originalEditorRef.current.getTopForLineNumber(zone.otherEditorStart + zone.otherEditorCount);
397+
const heightInPx = topEnd - topStart;
398+
zoneDef = heightInPx > 0 ? { ...zoneDef, heightInPx } : { ...zoneDef, heightInLines: zone.heightInLines };
399+
} else {
400+
zoneDef.heightInLines = zone.heightInLines;
401+
}
402+
const id = accessor.addZone(zoneDef);
352403
modifiedViewZoneIdsRef.current.push(id);
353404
});
354405
});
355406
}
356-
}, [originalText, modifiedText, options.ignoreWhitespace]);
407+
}, [originalText, modifiedText, options.ignoreWhitespace, originalWrapText, modifiedWrapText]);
357408

358-
// Apply decorations when diff calculation changes OR when editors become ready
409+
// Apply decorations when diff calculation changes, editors become ready, or zoom changes
359410
useEffect(() => {
360411
if (editorsReady) {
361412
calculateDiffAndDecorate();
362413
}
363-
}, [calculateDiffAndDecorate, editorsReady]);
414+
}, [calculateDiffAndDecorate, editorsReady, zoomEpoch]);
364415

365416
// Scroll sync between editors
366417
useEffect(() => {
@@ -482,15 +533,35 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
482533

483534
const handleOriginalEditorMount = (editor: any) => {
484535
originalEditorRef.current = editor;
485-
// Check if both editors are ready - the effect will handle decorations
536+
// Increment zoomEpoch when font size or line height changes so pixel-based
537+
// alignment zones are recomputed at the new scale.
538+
editor.onDidChangeConfiguration((e: any) => {
539+
const monaco = (window as any).monaco;
540+
if (
541+
monaco &&
542+
(e.hasChanged(monaco.editor.EditorOption.fontSize) ||
543+
e.hasChanged(monaco.editor.EditorOption.lineHeight))
544+
) {
545+
setZoomEpoch((n) => n + 1);
546+
}
547+
});
486548
if (modifiedEditorRef.current) {
487549
setEditorsReady(true);
488550
}
489551
};
490552

491553
const handleModifiedEditorMount = (editor: any) => {
492554
modifiedEditorRef.current = editor;
493-
// Check if both editors are ready - the effect will handle decorations
555+
editor.onDidChangeConfiguration((e: any) => {
556+
const monaco = (window as any).monaco;
557+
if (
558+
monaco &&
559+
(e.hasChanged(monaco.editor.EditorOption.fontSize) ||
560+
e.hasChanged(monaco.editor.EditorOption.lineHeight))
561+
) {
562+
setZoomEpoch((n) => n + 1);
563+
}
564+
});
494565
if (originalEditorRef.current) {
495566
setEditorsReady(true);
496567
}
@@ -553,8 +624,8 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
553624
</div>
554625

555626
{/* Body Section */}
556-
<div className="flex-1 bg-background px-[24px] pt-6 pb-10">
557-
<div className="flex flex-col gap-4">
627+
<div className="flex-1 flex flex-col bg-background px-[24px] pt-6 pb-10 min-h-0 overflow-y-auto">
628+
<div className="flex-1 flex flex-col gap-4 min-h-0">
558629
{/* Controls */}
559630
<div className="flex flex-col gap-4">
560631
{/* Main Controls Row */}
@@ -604,9 +675,9 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
604675
</div>
605676

606677
{/* Side-by-side Editor Panels */}
607-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
678+
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-0">
608679
{/* Original Panel */}
609-
<CodePanel
680+
<CodePanel fillHeight={true}
610681
title="Original"
611682
value={originalText}
612683
onChange={(value) => setOriginalText(value)}
@@ -655,7 +726,7 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
655726
/>
656727

657728
{/* Modified Panel */}
658-
<CodePanel
729+
<CodePanel fillHeight={true}
659730
title="Modified"
660731
value={modifiedText}
661732
onChange={(value) => setModifiedText(value)}

0 commit comments

Comments
 (0)