329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|