Files
kit-ui/components/calculate/ExpressionPanel.tsx

329 lines
13 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { Plus, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react';
import { useCalculateStore } from '@/lib/calculate/store';
import { evaluateExpression } from '@/lib/calculate/math-engine';
import { cn } from '@/lib/utils';
const QUICK_KEYS = [
// Constants
{ label: 'π', insert: 'pi', group: 'const' },
{ label: 'e', insert: 'e', group: 'const' },
{ label: 'φ', insert: '(1+sqrt(5))/2', group: 'const' },
{ label: '∞', insert: 'Infinity', group: 'const' },
{ label: 'i', insert: 'i', group: 'const' },
// Ops
{ label: '^', insert: '^', group: 'op' },
{ label: '(', insert: '(', group: 'op' },
{ label: ')', insert: ')', group: 'op' },
{ label: '%', insert: ' % ', group: 'op' },
{ label: 'mod', insert: ' mod ', group: 'op' },
// Functions
{ label: '√', insert: 'sqrt(', group: 'fn' },
{ label: '∛', insert: 'cbrt(', group: 'fn' },
{ label: '|x|', insert: 'abs(', group: 'fn' },
{ label: 'n!', insert: '!', group: 'fn' },
{ label: 'sin', insert: 'sin(', group: 'trig' },
{ label: 'cos', insert: 'cos(', group: 'trig' },
{ label: 'tan', insert: 'tan(', group: 'trig' },
{ label: 'asin', insert: 'asin(', group: 'trig' },
{ label: 'acos', insert: 'acos(', group: 'trig' },
{ label: 'atan', insert: 'atan(', group: 'trig' },
{ label: 'sinh', insert: 'sinh(', group: 'trig' },
{ label: 'cosh', insert: 'cosh(', group: 'trig' },
{ label: 'log', insert: 'log10(', group: 'log' },
{ label: 'ln', insert: 'log(', group: 'log' },
{ label: 'log₂', insert: 'log2(', group: 'log' },
{ label: 'exp', insert: 'exp(', group: 'log' },
{ label: 'floor', insert: 'floor(', group: 'round' },
{ label: 'ceil', insert: 'ceil(', group: 'round' },
{ label: 'round', insert: 'round(', group: 'round' },
{ label: 'gcd', insert: 'gcd(', group: 'misc' },
{ label: 'lcm', insert: 'lcm(', group: 'misc' },
{ label: 'nCr', insert: 'combinations(', group: 'misc' },
{ label: 'nPr', insert: 'permutations(', group: 'misc' },
] as const;
export function ExpressionPanel() {
const {
expression, setExpression,
history, addToHistory, clearHistory,
variables, setVariable, removeVariable,
} = useCalculateStore();
const [liveResult, setLiveResult] = useState<{ result: string; error: boolean } | null>(null);
const [newVarName, setNewVarName] = useState('');
const [newVarValue, setNewVarValue] = useState('');
const [showAddVar, setShowAddVar] = useState(false);
const [showAllKeys, setShowAllKeys] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Real-time evaluation
useEffect(() => {
if (!expression.trim()) { setLiveResult(null); return; }
const r = evaluateExpression(expression, variables);
setLiveResult(r.result ? { result: r.result, error: r.error } : null);
}, [expression, variables]);
const handleSubmit = useCallback(() => {
if (!expression.trim()) return;
const r = evaluateExpression(expression, variables);
if (!r.result) return;
addToHistory({ expression: expression.trim(), result: r.result, error: r.error });
if (!r.error) {
if (r.assignedName && r.assignedValue) {
setVariable(r.assignedName, r.assignedValue);
}
setExpression('');
}
}, [expression, variables, addToHistory, setExpression, setVariable]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}, [handleSubmit]);
const insertAtCursor = useCallback((text: string) => {
const ta = textareaRef.current;
if (!ta) { setExpression(expression + text); return; }
const start = ta.selectionStart;
const end = ta.selectionEnd;
const next = expression.slice(0, start) + text + expression.slice(end);
setExpression(next);
requestAnimationFrame(() => {
ta.focus();
const pos = start + text.length;
ta.selectionStart = ta.selectionEnd = pos;
});
}, [expression, setExpression]);
const addVar = useCallback(() => {
if (!newVarName.trim() || !newVarValue.trim()) return;
setVariable(newVarName.trim(), newVarValue.trim());
setNewVarName(''); setNewVarValue(''); setShowAddVar(false);
}, [newVarName, newVarValue, setVariable]);
const visibleKeys = showAllKeys ? QUICK_KEYS : QUICK_KEYS.slice(0, 16);
return (
<div className="flex flex-col gap-3 h-full overflow-hidden">
{/* ── Expression input ──────────────────────────────────── */}
<div className="glass rounded-xl p-4 shrink-0">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Expression
</span>
<span className="text-[10px] text-muted-foreground/50">
Enter to evaluate · Shift+Enter for newline
</span>
</div>
<textarea
ref={textareaRef}
value={expression}
onChange={(e) => setExpression(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g. sin(pi/4) * sqrt(2)"
rows={3}
className={cn(
'w-full bg-transparent resize-none font-mono text-sm outline-none',
'text-foreground placeholder:text-muted-foreground/35',
'border border-border/40 rounded-lg px-3 py-2.5',
'focus:border-primary/50 transition-colors'
)}
spellCheck={false}
autoComplete="off"
/>
{/* Result display */}
<div className="mt-3 flex items-baseline gap-2 min-h-[2rem]">
{liveResult && (
<>
<span className="font-mono text-muted-foreground shrink-0">=</span>
<span
className={cn(
'font-mono font-semibold break-all',
liveResult.error
? 'text-sm text-destructive/90'
: 'text-2xl bg-gradient-to-r from-primary to-pink-400 bg-clip-text text-transparent'
)}
>
{liveResult.result}
</span>
</>
)}
</div>
<button
onClick={handleSubmit}
disabled={!expression.trim()}
className={cn(
'mt-2 w-full py-2 rounded-lg text-sm font-medium transition-all',
'bg-primary/90 text-primary-foreground hover:bg-primary',
'disabled:opacity-30 disabled:cursor-not-allowed'
)}
>
Evaluate
</button>
</div>
{/* ── Quick insert keys ─────────────────────────────────── */}
<div className="glass rounded-xl p-3 shrink-0">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Insert
</span>
<button
onClick={() => setShowAllKeys((v) => !v)}
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
{showAllKeys ? (
<><ChevronUp className="w-3 h-3" /> less</>
) : (
<><ChevronDown className="w-3 h-3" /> more</>
)}
</button>
</div>
<div className="flex flex-wrap gap-1">
{visibleKeys.map((k) => (
<button
key={k.label}
onClick={() => insertAtCursor(k.insert)}
className={cn(
'px-2 py-1 text-xs font-mono rounded-md transition-all',
'glass border border-transparent',
'hover:border-primary/30 hover:bg-primary/10 hover:text-primary',
'text-foreground/80'
)}
>
{k.label}
</button>
))}
</div>
</div>
{/* ── Variables ─────────────────────────────────────────── */}
<div className="glass rounded-xl p-3 shrink-0">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Variables
</span>
<button
onClick={() => setShowAddVar((v) => !v)}
className="text-muted-foreground hover:text-primary transition-colors"
title="Add variable"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{Object.keys(variables).length === 0 && !showAddVar && (
<p className="text-xs text-muted-foreground/40 italic">
Define variables like <span className="font-mono not-italic">x = 5</span> by evaluating assignments
</p>
)}
<div className="space-y-1">
{Object.entries(variables).map(([name, val]) => (
<div key={name} className="flex items-center gap-2 group">
<span
className="font-mono text-sm text-primary cursor-pointer hover:underline"
onClick={() => insertAtCursor(name)}
title="Insert into expression"
>
{name}
</span>
<span className="text-muted-foreground/50 text-xs">=</span>
<span className="font-mono text-sm text-foreground/80 flex-1 truncate">{val}</span>
<button
onClick={() => removeVariable(name)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground/50 hover:text-destructive transition-all"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
{showAddVar && (
<div className="flex items-center gap-1.5 mt-2">
<input
value={newVarName}
onChange={(e) => setNewVarName(e.target.value)}
placeholder="name"
className="w-16 bg-transparent border border-border/40 rounded px-2 py-1 text-xs font-mono outline-none focus:border-primary/50 transition-colors"
/>
<span className="text-muted-foreground/50 text-xs">=</span>
<input
value={newVarValue}
onChange={(e) => setNewVarValue(e.target.value)}
placeholder="value"
onKeyDown={(e) => e.key === 'Enter' && addVar()}
className="flex-1 bg-transparent border border-border/40 rounded px-2 py-1 text-xs font-mono outline-none focus:border-primary/50 transition-colors"
/>
<button
onClick={addVar}
className="text-primary hover:text-primary/70 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setShowAddVar(false)}
className="text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* ── History ───────────────────────────────────────────── */}
<div className="glass rounded-xl p-3 flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex items-center justify-between mb-2 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
History
</span>
{history.length > 0 && (
<button
onClick={clearHistory}
className="text-muted-foreground/50 hover:text-destructive transition-colors"
title="Clear history"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{history.length === 0 ? (
<p className="text-xs text-muted-foreground/40 italic">No calculations yet</p>
) : (
<div className="overflow-y-auto flex-1 space-y-0.5 scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
{history.map((entry) => (
<button
key={entry.id}
onClick={() => setExpression(entry.expression)}
className="w-full text-left px-2 py-2 rounded-lg hover:bg-primary/8 group transition-colors"
>
<div className="font-mono text-[11px] text-muted-foreground/70 truncate group-hover:text-muted-foreground transition-colors">
{entry.expression}
</div>
<div className={cn(
'font-mono text-sm font-medium mt-0.5',
entry.error ? 'text-destructive/80' : 'text-foreground/90'
)}>
= {entry.result}
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}