Skip to content

Commit 38c42e2

Browse files
drakehanguyenDrakeNguyen
andauthored
feat: add LoadFileButton component and integrate it into DiffChecker, JsonFormatter, JsonPathFinder, and XmlFormatter tools (#72)
- Introduced LoadFileButton for file uploads across multiple tools. - Updated DiffChecker, JsonFormatter, JsonPathFinder, and XmlFormatter to utilize LoadFileButton for loading files, enhancing user experience. - Improved header actions in JsonFormatter and XmlFormatter to include file loading functionality. Co-authored-by: DrakeNguyen <drake.ha.nguyen@gmail.com>
1 parent b3f4868 commit 38c42e2

5 files changed

Lines changed: 270 additions & 125 deletions

File tree

src/components/tools/DiffChecker.tsx

Lines changed: 139 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ 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 { LoadFileButton } from '@/components/ui/load-file-button';
78
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
89
import { Switch } from '@/components/ui/switch';
910
import { DEFAULT_DIFF_OPTIONS, DIFF_CHECKER_OPTIONS, DIFF_EXAMPLES } from '@/config/diff-checker-config';
@@ -38,6 +39,18 @@ interface InlineDecoration {
3839
type: 'added' | 'removed';
3940
}
4041

42+
const EXTENSION_LANGUAGE_MAP: Record<string, string> = {
43+
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
44+
ts: 'typescript', tsx: 'typescript',
45+
json: 'json', html: 'html', htm: 'html',
46+
css: 'css', scss: 'css', less: 'css',
47+
py: 'python', java: 'java', cs: 'csharp',
48+
cpp: 'cpp', cc: 'cpp', go: 'go', rs: 'rust',
49+
sql: 'sql', xml: 'xml', svg: 'xml',
50+
yaml: 'yaml', yml: 'yaml', md: 'markdown',
51+
sh: 'shell', bash: 'shell', zsh: 'shell',
52+
};
53+
4154
export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
4255
const { toolState, updateToolState } = useToolState('diff-checker', instanceId);
4356

@@ -57,6 +70,10 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
5770
const originalDecorationsRef = useRef<string[]>([]);
5871
const modifiedDecorationsRef = useRef<string[]>([]);
5972

73+
// View zone IDs for alignment padding
74+
const originalViewZoneIdsRef = useRef<string[]>([]);
75+
const modifiedViewZoneIdsRef = useRef<string[]>([]);
76+
6077
// Scroll sync refs
6178
const isSyncingScrollRef = useRef<boolean>(false);
6279
const originalScrollDisposableRef = useRef<any>(null);
@@ -98,6 +115,11 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
98115
}
99116
}, [toolState, isHydrated]);
100117

118+
interface ViewZoneData {
119+
afterLineNumber: number;
120+
heightInLines: number;
121+
}
122+
101123
// Calculate diff and apply decorations with character-level highlighting
102124
const calculateDiffAndDecorate = useCallback(() => {
103125
const lineChanges: Change[] = diffLines(originalText, modifiedText, {
@@ -110,6 +132,8 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
110132
const modifiedLineDecorations: any[] = [];
111133
const originalInlineDecorations: InlineDecoration[] = [];
112134
const modifiedInlineDecorations: InlineDecoration[] = [];
135+
const originalViewZones: ViewZoneData[] = [];
136+
const modifiedViewZones: ViewZoneData[] = [];
113137

114138
let originalLine = 1;
115139
let modifiedLine = 1;
@@ -120,11 +144,7 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
120144
const lineCount = change.count || 0;
121145

122146
if (change.added) {
123-
// Check if previous was removed (paired change for inline diff)
124-
const prevChange = i > 0 ? lineChanges[i - 1] : null;
125-
if (prevChange && prevChange.removed) {
126-
// Already handled in removed case
127-
}
147+
// Standalone addition (not preceded by a removal that already handled it)
128148
additions += lineCount;
129149
const lines = (change.value || '').split('\n').filter((_, idx, arr) => idx < arr.length - 1 || arr[idx] !== '');
130150
lines.forEach((_, idx) => {
@@ -133,6 +153,8 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
133153
type: 'added',
134154
});
135155
});
156+
// Pad original side so unchanged lines below stay aligned
157+
originalViewZones.push({ afterLineNumber: originalLine - 1, heightInLines: lineCount });
136158
modifiedLine += lineCount;
137159
} else if (change.removed) {
138160
// Check if next is added (paired change for inline diff)
@@ -201,17 +223,33 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
201223
});
202224
}
203225

204-
additions += nextChange.count || 0;
205-
modifiedLine += nextChange.count || 0;
226+
const addedCount = nextChange.count || 0;
227+
additions += addedCount;
228+
229+
// Add alignment padding on the shorter side
230+
if (removedLines.length > addedLines.length) {
231+
modifiedViewZones.push({
232+
afterLineNumber: modifiedLine + addedLines.length - 1,
233+
heightInLines: removedLines.length - addedLines.length,
234+
});
235+
} else if (addedLines.length > removedLines.length) {
236+
originalViewZones.push({
237+
afterLineNumber: originalLine + removedLines.length - 1,
238+
heightInLines: addedLines.length - removedLines.length,
239+
});
240+
}
241+
242+
modifiedLine += addedCount;
206243
i++; // Skip the next (added) change since we handled it
207244
} else {
208-
// Unpaired removal
245+
// Isolated removal — pad modified side
209246
removedLines.forEach((_, idx) => {
210247
originalLineDecorations.push({
211248
lineNumber: originalLine + idx,
212249
type: 'removed',
213250
});
214251
});
252+
modifiedViewZones.push({ afterLineNumber: modifiedLine - 1, heightInLines: lineCount });
215253
}
216254
originalLine += lineCount;
217255
} else {
@@ -256,6 +294,22 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
256294
originalDecorationsRef.current,
257295
decorations
258296
);
297+
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+
});
259313
}
260314

261315
if (modifiedEditorRef.current) {
@@ -282,6 +336,22 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
282336
modifiedDecorationsRef.current,
283337
decorations
284338
);
339+
340+
// Apply alignment view zones
341+
editor.changeViewZones((accessor: any) => {
342+
modifiedViewZoneIdsRef.current.forEach((id) => accessor.removeZone(id));
343+
modifiedViewZoneIdsRef.current = [];
344+
modifiedViewZones.forEach((zone) => {
345+
const domNode = document.createElement('div');
346+
domNode.className = 'diff-placeholder-zone';
347+
const id = accessor.addZone({
348+
afterLineNumber: zone.afterLineNumber,
349+
heightInLines: zone.heightInLines,
350+
domNode,
351+
});
352+
modifiedViewZoneIdsRef.current.push(id);
353+
});
354+
});
285355
}
286356
}, [originalText, modifiedText, options.ignoreWhitespace]);
287357

@@ -461,6 +531,15 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
461531
background-color: rgba(34, 197, 94, 0.4) !important;
462532
border-radius: 2px;
463533
}
534+
.diff-placeholder-zone {
535+
background: repeating-linear-gradient(
536+
45deg,
537+
rgba(128, 128, 128, 0.08) 0px,
538+
rgba(128, 128, 128, 0.08) 4px,
539+
transparent 4px,
540+
transparent 8px
541+
);
542+
}
464543
`}</style>
465544

466545
{/* Header Section */}
@@ -539,22 +618,32 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
539618
showClearButton={true}
540619
showCopyButton={true}
541620
headerActions={
542-
<DropdownMenu>
543-
<DropdownMenuTrigger asChild>
544-
<Button variant="outline" size="sm" className="h-8 px-3 text-xs">
545-
Load Example
546-
<ChevronDownIcon className="h-3 w-3 ml-1" />
547-
</Button>
548-
</DropdownMenuTrigger>
549-
<DropdownMenuContent align="end">
550-
<DropdownMenuItem onClick={() => handleLoadExample('original', 'javascript')}>
551-
Load JavaScript Example
552-
</DropdownMenuItem>
553-
<DropdownMenuItem onClick={() => handleLoadExample('original', 'python')}>
554-
Load Python Example
555-
</DropdownMenuItem>
556-
</DropdownMenuContent>
557-
</DropdownMenu>
621+
<>
622+
<LoadFileButton
623+
onFileLoad={(content, file) => {
624+
setOriginalText(content);
625+
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
626+
const lang = EXTENSION_LANGUAGE_MAP[ext];
627+
if (lang) setOptions((prev) => ({ ...prev, language: lang }));
628+
}}
629+
/>
630+
<DropdownMenu>
631+
<DropdownMenuTrigger asChild>
632+
<Button variant="outline" size="sm" className="h-8 px-3 text-xs">
633+
Load Example
634+
<ChevronDownIcon className="h-3 w-3 ml-1" />
635+
</Button>
636+
</DropdownMenuTrigger>
637+
<DropdownMenuContent align="end">
638+
<DropdownMenuItem onClick={() => handleLoadExample('original', 'javascript')}>
639+
Load JavaScript Example
640+
</DropdownMenuItem>
641+
<DropdownMenuItem onClick={() => handleLoadExample('original', 'python')}>
642+
Load Python Example
643+
</DropdownMenuItem>
644+
</DropdownMenuContent>
645+
</DropdownMenu>
646+
</>
558647
}
559648
footerLeftContent={
560649
<>
@@ -578,22 +667,32 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) {
578667
showClearButton={true}
579668
showCopyButton={true}
580669
headerActions={
581-
<DropdownMenu>
582-
<DropdownMenuTrigger asChild>
583-
<Button variant="outline" size="sm" className="h-8 px-3 text-xs">
584-
Load Example
585-
<ChevronDownIcon className="h-3 w-3 ml-1" />
586-
</Button>
587-
</DropdownMenuTrigger>
588-
<DropdownMenuContent align="end">
589-
<DropdownMenuItem onClick={() => handleLoadExample('modified', 'javascript')}>
590-
Load JavaScript Example
591-
</DropdownMenuItem>
592-
<DropdownMenuItem onClick={() => handleLoadExample('modified', 'python')}>
593-
Load Python Example
594-
</DropdownMenuItem>
595-
</DropdownMenuContent>
596-
</DropdownMenu>
670+
<>
671+
<LoadFileButton
672+
onFileLoad={(content, file) => {
673+
setModifiedText(content);
674+
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
675+
const lang = EXTENSION_LANGUAGE_MAP[ext];
676+
if (lang) setOptions((prev) => ({ ...prev, language: lang }));
677+
}}
678+
/>
679+
<DropdownMenu>
680+
<DropdownMenuTrigger asChild>
681+
<Button variant="outline" size="sm" className="h-8 px-3 text-xs">
682+
Load Example
683+
<ChevronDownIcon className="h-3 w-3 ml-1" />
684+
</Button>
685+
</DropdownMenuTrigger>
686+
<DropdownMenuContent align="end">
687+
<DropdownMenuItem onClick={() => handleLoadExample('modified', 'javascript')}>
688+
Load JavaScript Example
689+
</DropdownMenuItem>
690+
<DropdownMenuItem onClick={() => handleLoadExample('modified', 'python')}>
691+
Load Python Example
692+
</DropdownMenuItem>
693+
</DropdownMenuContent>
694+
</DropdownMenu>
695+
</>
597696
}
598697
footerLeftContent={
599698
<>

src/components/tools/JsonFormatter.tsx

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ 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 { LoadFileButton } from '@/components/ui/load-file-button';
78
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
89
import { DEFAULT_JSON_OPTIONS, JSON_EXAMPLES, JSON_FORMAT_OPTIONS } from '@/config/json-formatter-config';
910
import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme';
@@ -215,29 +216,38 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) {
215216
showCopyButton={false}
216217
showClearButton={true}
217218
headerActions={
218-
<DropdownMenu>
219-
<DropdownMenuTrigger asChild>
220-
<Button
221-
variant="outline"
222-
size="sm"
223-
className="h-8 px-3 text-xs"
224-
>
225-
Load Examples
226-
<ChevronDownIcon className="h-3 w-3 ml-1" />
227-
</Button>
228-
</DropdownMenuTrigger>
229-
<DropdownMenuContent align="end">
230-
<DropdownMenuItem onClick={() => handleLoadExample('valid')}>
231-
Load Valid Example
232-
</DropdownMenuItem>
233-
<DropdownMenuItem onClick={() => handleLoadExample('minified')}>
234-
Load Minified Example
235-
</DropdownMenuItem>
236-
<DropdownMenuItem onClick={() => handleLoadExample('invalid')}>
237-
Load Invalid Example
238-
</DropdownMenuItem>
239-
</DropdownMenuContent>
240-
</DropdownMenu>
219+
<>
220+
<LoadFileButton
221+
accept=".json,.json5,*/*"
222+
onFileLoad={(content) => {
223+
setInput(content);
224+
setError('');
225+
}}
226+
/>
227+
<DropdownMenu>
228+
<DropdownMenuTrigger asChild>
229+
<Button
230+
variant="outline"
231+
size="sm"
232+
className="h-8 px-3 text-xs"
233+
>
234+
Load Examples
235+
<ChevronDownIcon className="h-3 w-3 ml-1" />
236+
</Button>
237+
</DropdownMenuTrigger>
238+
<DropdownMenuContent align="end">
239+
<DropdownMenuItem onClick={() => handleLoadExample('valid')}>
240+
Load Valid Example
241+
</DropdownMenuItem>
242+
<DropdownMenuItem onClick={() => handleLoadExample('minified')}>
243+
Load Minified Example
244+
</DropdownMenuItem>
245+
<DropdownMenuItem onClick={() => handleLoadExample('invalid')}>
246+
Load Invalid Example
247+
</DropdownMenuItem>
248+
</DropdownMenuContent>
249+
</DropdownMenu>
250+
</>
241251
}
242252
footerLeftContent={
243253
<span>{getCharacterCount(input)} characters</span>

0 commit comments

Comments
 (0)