refactor(ascii): align layout and UX with Calculate blueprint
Rewrites all four ASCII tool components to share the same design language and spatial structure as the Calculator & Grapher tool. Layout - New responsive 2/5–3/5 grid (was fixed 2+1 col); matches Calculate - Left panel: text input card + font selector filling remaining height - Right panel: preview as the dominant full-height element - Mobile: tabbed Editor / Preview switcher (same pattern as Calculator) TextInput - Replace shadcn Textarea with native <textarea> - Glass border pattern (border-border/40, focus:border-primary/50) - Monospace font, consistent counter styling FontSelector - Replace Card + shadcn Tabs + Button + Input + Empty with native elements - Glass panel (glass rounded-xl) matching Calculate panel style - Custom tab strip mirrors Calculator mobile tab pattern - Native search input with glass border - Font list items: border-l-2 left accent for selected state, hover:bg-primary/8, rose heart for favorites - Auto-scrolls selected item into view on external changes - Simplified empty state to single italic line FontPreview - Replace Card + Button + Badge + ToggleGroup + Tooltip + Empty - Glass panel with header row (label + font tag + action buttons) - Controls row: native toggle buttons with primary/10 active state - Terminal window: dark #06060e background, macOS-style chrome (rose/amber/emerald dots), font name watermark — the hero element - PNG export captures entire terminal including chrome at 2x - Inline skeleton loader with pulse animation replaces Skeleton import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,6 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -15,18 +10,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty"
|
||||
import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight, Type, MessageSquareCode } from 'lucide-react';
|
||||
Copy,
|
||||
Download,
|
||||
Share2,
|
||||
Image as ImageIcon,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
MessageSquareCode,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -34,12 +27,12 @@ export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '<!-- --
|
||||
|
||||
const COMMENT_STYLES: { value: CommentStyle; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: '//', label: '// C, JS, Go' },
|
||||
{ value: '#', label: '# Python, Shell' },
|
||||
{ value: '--', label: '-- SQL, Lua' },
|
||||
{ value: ';', label: '; Lisp, ASM' },
|
||||
{ value: '/* */', label: '/* */ Block' },
|
||||
{ value: '<!-- -->', label: '<!-- --> HTML' },
|
||||
{ value: '//', label: '// C / JS / Go' },
|
||||
{ value: '#', label: '# Python / Shell' },
|
||||
{ value: '--', label: '-- SQL / Lua' },
|
||||
{ value: ';', label: '; Lisp / ASM' },
|
||||
{ value: '/* */', label: '/* Block */' },
|
||||
{ value: '<!-- -->', label: '<!-- HTML -->' },
|
||||
{ value: '"""', label: '""" Docstring' },
|
||||
];
|
||||
|
||||
@@ -51,9 +44,9 @@ function applyCommentStyle(text: string, style: CommentStyle): string {
|
||||
case '#':
|
||||
case '--':
|
||||
case ';':
|
||||
return lines.map(line => `${style} ${line}`).join('\n');
|
||||
return lines.map((l) => `${style} ${l}`).join('\n');
|
||||
case '/* */':
|
||||
return ['/*', ...lines.map(line => ` * ${line}`), ' */'].join('\n');
|
||||
return ['/*', ...lines.map((l) => ` * ${l}`), ' */'].join('\n');
|
||||
case '<!-- -->':
|
||||
return ['<!--', ...lines, '-->'].join('\n');
|
||||
case '"""':
|
||||
@@ -73,14 +66,39 @@ export interface FontPreviewProps {
|
||||
}
|
||||
|
||||
type TextAlign = 'left' | 'center' | 'right';
|
||||
type FontSize = 'xs' | 'sm' | 'base';
|
||||
|
||||
export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare, onCommentedTextChange, className }: FontPreviewProps) {
|
||||
const previewRef = React.useRef<HTMLDivElement>(null);
|
||||
const ALIGN_OPTS: { value: TextAlign; icon: React.ElementType; label: string }[] = [
|
||||
{ value: 'left', icon: AlignLeft, label: 'Left' },
|
||||
{ value: 'center', icon: AlignCenter, label: 'Center' },
|
||||
{ value: 'right', icon: AlignRight, label: 'Right' },
|
||||
];
|
||||
|
||||
const SIZE_OPTS: { value: FontSize; label: string }[] = [
|
||||
{ value: 'xs', label: 'xs' },
|
||||
{ value: 'sm', label: 'sm' },
|
||||
{ value: 'base', label: 'md' },
|
||||
];
|
||||
|
||||
export function FontPreview({
|
||||
text,
|
||||
font,
|
||||
isLoading,
|
||||
onCopy,
|
||||
onDownload,
|
||||
onShare,
|
||||
onCommentedTextChange,
|
||||
className,
|
||||
}: FontPreviewProps) {
|
||||
const terminalRef = React.useRef<HTMLDivElement>(null);
|
||||
const [textAlign, setTextAlign] = React.useState<TextAlign>('left');
|
||||
const [fontSize, setFontSize] = React.useState<'xs' | 'sm' | 'base'>('sm');
|
||||
const [fontSize, setFontSize] = React.useState<FontSize>('sm');
|
||||
const [commentStyle, setCommentStyle] = React.useState<CommentStyle>('none');
|
||||
|
||||
const commentedText = React.useMemo(() => applyCommentStyle(text, commentStyle), [text, commentStyle]);
|
||||
const commentedText = React.useMemo(
|
||||
() => applyCommentStyle(text, commentStyle),
|
||||
[text, commentStyle]
|
||||
);
|
||||
const lineCount = commentedText ? commentedText.split('\n').length : 0;
|
||||
const charCount = commentedText ? commentedText.length : 0;
|
||||
|
||||
@@ -89,183 +107,181 @@ export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare
|
||||
}, [commentedText, onCommentedTextChange]);
|
||||
|
||||
const handleExportPNG = async () => {
|
||||
if (!previewRef.current || !text) return;
|
||||
|
||||
if (!terminalRef.current || !text) return;
|
||||
try {
|
||||
const dataUrl = await toPng(previewRef.current, {
|
||||
backgroundColor: getComputedStyle(previewRef.current).backgroundColor,
|
||||
const dataUrl = await toPng(terminalRef.current, {
|
||||
backgroundColor: '#06060e',
|
||||
pixelRatio: 2,
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `ascii-${font || 'export'}-${Date.now()}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
|
||||
toast.success('Exported as PNG!');
|
||||
} catch (error) {
|
||||
console.error('Failed to export PNG:', error);
|
||||
} catch {
|
||||
toast.error('Failed to export PNG');
|
||||
}
|
||||
};
|
||||
|
||||
const actionBtn =
|
||||
'flex items-center gap-1 px-2.5 py-1 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all';
|
||||
|
||||
return (
|
||||
<Card className={cn('relative', className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className={cn('glass rounded-xl p-4 flex flex-col gap-3 flex-1 min-h-0 overflow-hidden', className)}>
|
||||
|
||||
{/* ── Header: label + font tag + export actions ─────────── */}
|
||||
<div className="flex items-center justify-between gap-2 shrink-0 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Preview
|
||||
</span>
|
||||
{font && (
|
||||
<Badge className="text-[10px] font-mono">
|
||||
<span className="px-2 py-0.5 rounded-md bg-primary/10 text-primary text-[10px] font-mono border border-primary/20">
|
||||
{font}
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{onCopy && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onCopy}>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
Copy
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
<button onClick={onCopy} className={actionBtn}>
|
||||
<Copy className="w-3 h-3" /> Copy
|
||||
</button>
|
||||
)}
|
||||
{onShare && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onShare}>
|
||||
<Share2 className="h-3 w-3 mr-1" />
|
||||
Share
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy shareable URL</TooltipContent>
|
||||
</Tooltip>
|
||||
<button onClick={onShare} className={actionBtn}>
|
||||
<Share2 className="w-3 h-3" /> Share
|
||||
</button>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={handleExportPNG}>
|
||||
<ImageIcon className="h-3 w-3 mr-1" />
|
||||
PNG
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Export as PNG</TooltipContent>
|
||||
</Tooltip>
|
||||
<button onClick={handleExportPNG} className={actionBtn}>
|
||||
<ImageIcon className="w-3 h-3" /> PNG
|
||||
</button>
|
||||
{onDownload && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onDownload}>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
TXT
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download as text file</TooltipContent>
|
||||
</Tooltip>
|
||||
<button onClick={onDownload} className={actionBtn}>
|
||||
<Download className="w-3 h-3" /> TXT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={textAlign}
|
||||
onValueChange={(v) => v && setTextAlign(v as TextAlign)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={commentStyle !== 'none'}
|
||||
>
|
||||
<ToggleGroupItem value="left" aria-label="Align left" className="px-1.5">
|
||||
<AlignLeft className="h-3 w-3" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="center" aria-label="Align center" className="px-1.5">
|
||||
<AlignCenter className="h-3 w-3" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="right" aria-label="Align right" className="px-1.5">
|
||||
<AlignRight className="h-3 w-3" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={fontSize}
|
||||
onValueChange={(v) => v && setFontSize(v as 'xs' | 'sm' | 'base')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="xs" aria-label="Extra small font" className="px-1.5 text-[10px] uppercase">
|
||||
xs
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="sm" aria-label="Small font" className="px-1.5 text-[10px] uppercase">
|
||||
sm
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="base" aria-label="Medium font" className="px-1.5 text-[10px] uppercase">
|
||||
md
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{/* ── Controls: alignment · size · comment style ─────────── */}
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
{/* Alignment */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{ALIGN_OPTS.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setTextAlign(value)}
|
||||
disabled={commentStyle !== 'none'}
|
||||
title={label}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md transition-all border text-xs',
|
||||
textAlign === value && commentStyle === 'none'
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40',
|
||||
commentStyle !== 'none' && 'opacity-30 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Select value={commentStyle} onValueChange={(v) => setCommentStyle(v as CommentStyle)}>
|
||||
<SelectTrigger size="sm" className="h-8 w-auto gap-1 text-xs">
|
||||
<MessageSquareCode className="h-3 w-3 text-foreground shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMMENT_STYLES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Font size */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{SIZE_OPTS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setFontSize(value)}
|
||||
className={cn(
|
||||
'px-2 py-1 text-[10px] font-mono rounded-md transition-all border uppercase',
|
||||
fontSize === value
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isLoading && text && (
|
||||
<div className="flex gap-2 text-[10px] text-muted-foreground ml-auto">
|
||||
<span>{lineCount} lines</span>
|
||||
<span>{charCount} chars</span>
|
||||
</div>
|
||||
{/* Comment style */}
|
||||
<Select value={commentStyle} onValueChange={(v) => setCommentStyle(v as CommentStyle)}>
|
||||
<SelectTrigger className="h-7 w-auto gap-1.5 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors">
|
||||
<MessageSquareCode className="w-3 h-3 text-muted-foreground/60 shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMMENT_STYLES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Stats */}
|
||||
{!isLoading && text && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/30 font-mono tabular-nums">
|
||||
{lineCount}L · {charCount}C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Terminal window ────────────────────────────────────── */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 min-h-0 flex flex-col rounded-xl overflow-hidden border border-white/5"
|
||||
style={{ background: '#06060e' }}
|
||||
>
|
||||
{/* Terminal chrome */}
|
||||
<div className="flex items-center gap-1.5 px-3.5 py-2 border-b border-white/5 shrink-0">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-rose-500/55" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-400/55" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/55" />
|
||||
{font && (
|
||||
<span className="ml-2 text-[10px] font-mono text-white/20 tracking-wider select-none">
|
||||
{font}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className={cn(
|
||||
'relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto',
|
||||
commentStyle === 'none' && textAlign === 'center' && 'text-center',
|
||||
commentStyle === 'none' && textAlign === 'right' && 'text-right'
|
||||
)}
|
||||
className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-white/8 scrollbar-track-transparent"
|
||||
style={{ textAlign: commentStyle === 'none' ? textAlign : 'left' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-5/6" />
|
||||
<Skeleton className="h-6 w-2/3" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-4/5" />
|
||||
<div className="space-y-2 animate-pulse">
|
||||
{[0.7, 1, 0.85, 0.55, 1, 0.9, 0.75].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-3.5 rounded-sm bg-white/5"
|
||||
style={{ width: `${w * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : text ? (
|
||||
<pre className={cn(
|
||||
'font-mono whitespace-pre overflow-x-auto animate-in',
|
||||
fontSize === 'xs' && 'text-[10px]',
|
||||
fontSize === 'sm' && 'text-xs sm:text-sm',
|
||||
fontSize === 'base' && 'text-sm sm:text-base'
|
||||
)}>
|
||||
<pre
|
||||
className={cn(
|
||||
'font-mono whitespace-pre text-white/85 leading-snug',
|
||||
fontSize === 'xs' && 'text-[9px]',
|
||||
fontSize === 'sm' && 'text-[11px] sm:text-xs',
|
||||
fontSize === 'base' && 'text-xs sm:text-sm'
|
||||
)}
|
||||
>
|
||||
{commentedText}
|
||||
</pre>
|
||||
) : (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Type />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Start typing to see your ASCII art</EmptyTitle>
|
||||
<EmptyDescription>Enter text in the input field above to generate ASCII art with the selected font</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
<div className="h-full flex flex-col items-center justify-center gap-2 text-center">
|
||||
<Type className="w-6 h-6 text-white/10" />
|
||||
<p className="text-xs text-white/20 font-mono">
|
||||
Start typing to see your ASCII art
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user