Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions docs/superpowers/specs/2026-04-14-math-symbols-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# 数学符号功能设计文档

## 1. 项目背景

当前项目是一个数学函数可视化工具,允许用户输入函数表达式并在坐标系中绘制函数图像。为了提升用户体验,需要添加数学符号选择功能,便于用户更方便地编辑数学公式。

## 2. 功能需求

### 2.1 数学符号支持
- **基础运算符**:加(+)、减(-)、乘(*)、除(/)、幂(^)、括号(()、[])、逗号(,)
- **三角函数**:sin、cos、tan、asin、acos、atan
- **指数对数**:exp、log、ln、sqrt
- **常量**:π、e
- **其他符号**:绝对值(|x|)、阶乘(!)、分号(;)

### 2.2 符号选择界面
- 使用下拉菜单形式呈现数学符号
- 按基础分类组织符号:运算符、三角函数、指数对数、常量、其他
- 点击符号后自动插入到输入框的光标位置

### 2.3 高级编辑功能
- 括号匹配提示
- 表达式格式化
- 输入验证和错误提示

## 3. 技术实现方案

### 3.1 组件修改
- 在现有的 `FunctionInput` 组件中添加数学符号选择功能
- 保持与现有代码风格和架构的一致性

### 3.2 实现细节

#### 3.2.1 数学符号数据结构
```typescript
interface MathSymbol {
label: string; // 显示名称
value: string; // 实际插入的符号
category: string; // 分类
}

const mathSymbols: MathSymbol[] = [
// 运算符
{ label: '加法 (+)', value: '+', category: '运算符' },
{ label: '减法 (-)', value: '-', category: '运算符' },
{ label: '乘法 (*)', value: '*', category: '运算符' },
{ label: '除法 (/)', value: '/', category: '运算符' },
{ label: '幂 (^)', value: '^', category: '运算符' },
{ label: '左括号 (', value: '(', category: '运算符' },
{ label: '右括号 )', value: ')', category: '运算符' },
{ label: '逗号 ,', value: ',', category: '运算符' },

// 三角函数
{ label: 'sin', value: 'sin()', category: '三角函数' },
{ label: 'cos', value: 'cos()', category: '三角函数' },
{ label: 'tan', value: 'tan()', category: '三角函数' },
{ label: 'asin', value: 'asin()', category: '三角函数' },
{ label: 'acos', value: 'acos()', category: '三角函数' },
{ label: 'atan', value: 'atan()', category: '三角函数' },

// 指数对数
{ label: 'exp', value: 'exp()', category: '指数对数' },
{ label: 'log', value: 'log()', category: '指数对数' },
{ label: 'ln', value: 'ln()', category: '指数对数' },
{ label: 'sqrt', value: 'sqrt()', category: '指数对数' },

// 常量
{ label: 'π', value: 'pi', category: '常量' },
{ label: 'e', value: 'e', category: '常量' },

// 其他
{ label: '绝对值 |x|', value: 'abs()', category: '其他' },
{ label: '阶乘 !', value: '!', category: '其他' },
{ label: '分号 ;', value: ';', category: '其他' },
];
```

#### 3.2.2 组件实现
- 添加下拉菜单按钮和符号选择列表
- 实现符号点击插入功能,支持光标位置插入
- 添加括号匹配和表达式格式化功能
- 保持现有的函数验证逻辑

### 3.3 用户界面设计
- 在函数输入框旁边添加一个「数学符号」按钮
- 点击按钮展开下拉菜单,按分类显示符号
- 符号按分类分组,每个分类有标题
- 支持搜索符号功能

## 4. 实现步骤

1. **修改 FunctionInput 组件**:
- 添加数学符号数据和分类
- 实现下拉菜单 UI
- 添加符号插入逻辑

2. **添加高级编辑功能**:
- 实现括号匹配提示
- 添加表达式格式化功能

3. **测试和优化**:
- 测试各种符号的插入和验证
- 优化用户交互体验
- 确保与现有功能的兼容性

## 5. 预期效果

- 用户可以通过点击下拉菜单中的符号快速插入到函数表达式中
- 支持按分类浏览和选择数学符号
- 提供括号匹配和表达式格式化等高级编辑功能
- 保持与现有功能的无缝集成

## 6. 技术依赖

- 现有技术栈:React、TypeScript、Tailwind CSS、mathjs
- 无需添加新的依赖库

## 7. 实现风险

- 下拉菜单的定位和显示可能需要处理不同屏幕尺寸的适配
- 符号插入时的光标位置计算需要考虑各种输入场景
- 高级编辑功能可能需要额外的状态管理

## 8. 解决方案

- 使用 Tailwind CSS 的响应式设计确保下拉菜单在不同屏幕尺寸下正常显示
- 利用 React 的 useRef 钩子获取输入框的光标位置
- 采用模块化设计,将符号数据和插入逻辑分离,便于维护和扩展
177 changes: 162 additions & 15 deletions src/components/FunctionInput.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { validateFunction } from '../utils/math';

interface FunctionInputProps {
onAddFunction: (expression: string) => void;
}

interface MathSymbol {
label: string;
value: string;
category: string;
}

const mathSymbols: MathSymbol[] = [
{ label: '加法 (+)', value: '+', category: '运算符' },
{ label: '减法 (-)', value: '-', category: '运算符' },
{ label: '乘法 (*)', value: '*', category: '运算符' },
{ label: '除法 (/)', value: '/', category: '运算符' },
{ label: '幂 (^)', value: '^', category: '运算符' },
{ label: '左括号 (', value: '(', category: '运算符' },
{ label: '右括号 )', value: ')', category: '运算符' },
{ label: '逗号 ,', value: ',', category: '运算符' },
{ label: 'sin', value: 'sin()', category: '三角函数' },
{ label: 'cos', value: 'cos()', category: '三角函数' },
{ label: 'tan', value: 'tan()', category: '三角函数' },
{ label: 'asin', value: 'asin()', category: '三角函数' },
{ label: 'acos', value: 'acos()', category: '三角函数' },
{ label: 'atan', value: 'atan()', category: '三角函数' },
{ label: 'exp', value: 'exp()', category: '指数对数' },
{ label: 'log', value: 'log()', category: '指数对数' },
{ label: 'ln', value: 'ln()', category: '指数对数' },
{ label: 'sqrt', value: 'sqrt()', category: '指数对数' },
{ label: 'π', value: 'pi', category: '常量' },
{ label: 'e', value: 'e', category: '常量' },
{ label: '绝对值 |x|', value: 'abs()', category: '其他' },
{ label: '阶乘 !', value: '!', category: '其他' },
{ label: '分号 ;', value: ';', category: '其他' },
];

const categories = Array.from(new Set(mathSymbols.map(symbol => symbol.category)));

const FunctionInput: React.FC<FunctionInputProps> = ({ onAddFunction }) => {
const [expression, setExpression] = useState('');
const [error, setError] = useState('');
const [showSymbols, setShowSymbols] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -24,28 +60,139 @@ const FunctionInput: React.FC<FunctionInputProps> = ({ onAddFunction }) => {
}
};

const insertSymbol = (symbol: string) => {
const input = inputRef.current;
if (input) {
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const newValue = expression.substring(0, start) + symbol + expression.substring(end);
setExpression(newValue);

setTimeout(() => {
input.focus();
const newPosition = start + symbol.length;
input.setSelectionRange(newPosition, newPosition);
}, 0);
}
};

const formatExpression = () => {
// 简单的表达式格式化逻辑
let formatted = expression
.replace(/\s+/g, ' ')
.replace(/\s*([+\-*/^()])\s*/g, ' $1 ')
.trim();
setExpression(formatted);
};

const toggleSymbols = () => {
setShowSymbols(!showSymbols);
};

// 点击外部关闭下拉菜单
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (showSymbols && !target.closest('.relative') && !target.closest('.z-10')) {
setShowSymbols(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showSymbols]);

// 括号匹配检查
const checkBracketBalance = () => {
const openBrackets = (expression.match(/\(/g) || []).length;
const closeBrackets = (expression.match(/\)/g) || []).length;
return openBrackets === closeBrackets;
};

return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="function" className="block text-sm font-medium text-gray-700 mb-1">
函数表达式
</label>
<input
type="text"
id="function"
value={expression}
onChange={(e) => setExpression(e.target.value)}
placeholder="例如: sin(x) 或 x^2"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="relative">
<input
ref={inputRef}
type="text"
id="function"
value={expression}
onChange={(e) => setExpression(e.target.value)}
placeholder="例如: sin(x) 或 x^2"
className={`w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
!checkBracketBalance() ? 'border-red-500' : 'border-gray-300'
}`}
/>
<button
type="button"
onClick={toggleSymbols}
className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded text-sm"
>
数学符号
</button>
</div>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
{!checkBracketBalance() && (
<p className="mt-1 text-sm text-amber-600">括号不匹配,请检查</p>
)}
</div>

{showSymbols && (
<div className="absolute z-10 bg-white border border-gray-300 rounded-md shadow-lg p-4 w-80 max-h-96 overflow-y-auto mt-1">
<div className="flex justify-between items-center mb-2">
<h3 className="font-medium">选择数学符号</h3>
<button
type="button"
onClick={toggleSymbols}
className="text-gray-500 hover:text-gray-700"
>
×
</button>
</div>
{categories.map(category => (
<div key={category} className="mb-3">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">{category}</h4>
<div className="flex flex-wrap gap-1">
{mathSymbols
.filter(symbol => symbol.category === category)
.map((symbol, index) => (
<button
key={index}
type="button"
onClick={() => insertSymbol(symbol.value)}
className="px-2 py-1 text-sm border border-gray-200 rounded hover:bg-gray-100"
>
{symbol.label}
</button>
))
}
</div>
</div>
))}
</div>
)}

<div className="flex gap-2">
<button
type="button"
onClick={formatExpression}
className="flex-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
格式化表达式
</button>
<button
type="submit"
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
添加函数
</button>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
添加函数
</button>
</form>
);
};
Expand Down