|
|
@@ -1,2532 +1,2532 @@
|
|
|
-// 控制台交互逻辑
|
|
|
-class SymjaConsole {
|
|
|
- constructor() {
|
|
|
- this.output = document.getElementById('output');
|
|
|
- this.input = document.getElementById('input');
|
|
|
- this.inputPrompt = document.getElementById('inputPrompt');
|
|
|
- this.completionList = document.getElementById('completionList');
|
|
|
- this.helpBtn = document.getElementById('helpBtn');
|
|
|
- this.pluginsBtn = document.getElementById('pluginsBtn');
|
|
|
- this.settingsBtn = document.getElementById('settingsBtn');
|
|
|
-
|
|
|
- // 历史记录
|
|
|
- this.history = this.loadHistory();
|
|
|
- this.historyIndex = this.history.length;
|
|
|
- this.tempInput = '';
|
|
|
-
|
|
|
- // 插件相关
|
|
|
- this.plugins = [];
|
|
|
- this.currentPlugin = null;
|
|
|
-
|
|
|
- // 自动补全相关
|
|
|
- this.completions = [];
|
|
|
- this.completionIndex = -1;
|
|
|
-
|
|
|
- // 变量追踪
|
|
|
- this.variables = new Set();
|
|
|
-
|
|
|
- // 执行计数器
|
|
|
- this.executionCount = this.loadExecutionCount();
|
|
|
-
|
|
|
- this.init();
|
|
|
- this.loadPlugins();
|
|
|
- this.refreshPluginList(); // 初始化时刷新插件列表
|
|
|
- this.showWelcome();
|
|
|
- this.updatePrompt();
|
|
|
- }
|
|
|
-
|
|
|
- init() {
|
|
|
- // 输入框事件
|
|
|
- this.input.addEventListener('keydown', (e) => this.handleKeyDown(e));
|
|
|
- this.input.addEventListener('input', () => this.handleInput());
|
|
|
-
|
|
|
- // 发送按钮
|
|
|
- const sendBtn = document.getElementById('sendBtn');
|
|
|
- if (sendBtn) {
|
|
|
- sendBtn.addEventListener('click', () => this.evaluate());
|
|
|
- }
|
|
|
-
|
|
|
- // 顶部导航按钮
|
|
|
- this.helpBtn?.addEventListener('click', () => this.showHelp());
|
|
|
- this.pluginsBtn?.addEventListener('click', () => this.showPluginPanel());
|
|
|
- this.settingsBtn?.addEventListener('click', () => this.showSettings());
|
|
|
-
|
|
|
- // 设置面板
|
|
|
- document.getElementById('closeSettings')?.addEventListener('click', () => this.hideSettings());
|
|
|
- document.getElementById('applySettings')?.addEventListener('click', () => this.applySettings());
|
|
|
-
|
|
|
- // 加载已保存的设置
|
|
|
- this.loadSettings();
|
|
|
-
|
|
|
- // MCP 事件轮询(仅在服务器开启时运行)
|
|
|
- this.mcpEventCount = 0;
|
|
|
- this.mcpPollTimer = null;
|
|
|
- this.startMcpPollingIfNeeded();
|
|
|
-
|
|
|
- // 快捷键工具栏
|
|
|
- this.initShortcutsBar();
|
|
|
-
|
|
|
- // 帮助页面关闭按钮
|
|
|
- document.getElementById('closeHelp')?.addEventListener('click', () => {
|
|
|
- this.hideHelp();
|
|
|
- });
|
|
|
-
|
|
|
- // 恢复默认插件
|
|
|
- document.getElementById('restorePluginsBtn')?.addEventListener('click', () => {
|
|
|
- try {
|
|
|
- const result = Android.restoreDefaultPlugins();
|
|
|
- this.loadPlugins();
|
|
|
- this.refreshPluginList();
|
|
|
- this.addOutput(result, 'help');
|
|
|
- this.scrollToBottom();
|
|
|
- } catch(e) {
|
|
|
- this.addOutput('❌ 恢复失败: ' + e.message, 'error');
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // 全屏面板关闭
|
|
|
- document.getElementById('closeFullscreen')?.addEventListener('click', () => {
|
|
|
- const fs = document.getElementById('fullscreenPanel');
|
|
|
- fs.classList.remove('show');
|
|
|
- // 调用插件的 IJS.ui.close() 进行清理(停止音乐、动画等)
|
|
|
- if (this._lastIjsApi && this._lastIjsApi.ui) {
|
|
|
- this._lastIjsApi.ui.close();
|
|
|
- }
|
|
|
- // 确保动画停止
|
|
|
- if (this._lastIjsApi && this._lastIjsApi.canvas) {
|
|
|
- this._lastIjsApi.canvas.stop();
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // 插件面板事件
|
|
|
- document.getElementById('closePluginPanel')?.addEventListener('click', () => {
|
|
|
- this.hidePluginPanel();
|
|
|
- });
|
|
|
-
|
|
|
- document.getElementById('closePluginDialog')?.addEventListener('click', () => {
|
|
|
- this.hidePluginDialog();
|
|
|
- });
|
|
|
-
|
|
|
- document.getElementById('cancelPlugin')?.addEventListener('click', () => {
|
|
|
- this.hidePluginDialog();
|
|
|
- });
|
|
|
-
|
|
|
- document.getElementById('executePlugin')?.addEventListener('click', () => {
|
|
|
- this.executeCurrentPlugin();
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- initShortcutsBar() {
|
|
|
- const buttons = document.querySelectorAll('.shortcut-btn');
|
|
|
- buttons.forEach(btn => {
|
|
|
- btn.addEventListener('click', (e) => {
|
|
|
- e.preventDefault();
|
|
|
- const action = btn.dataset.action;
|
|
|
- const value = btn.dataset.value;
|
|
|
-
|
|
|
- switch(action) {
|
|
|
- case 'bracket':
|
|
|
- case 'insert':
|
|
|
- this.insertText(value);
|
|
|
- break;
|
|
|
- case 'history-up':
|
|
|
- this.navigateHistory(-1);
|
|
|
- break;
|
|
|
- case 'history-down':
|
|
|
- this.navigateHistory(1);
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- // 保持输入框焦点
|
|
|
- this.input.focus();
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- // 初始化插件标签页
|
|
|
- this.initPluginTabs();
|
|
|
-
|
|
|
- // 初始化插件分享功能
|
|
|
- this.initPluginShare();
|
|
|
- }
|
|
|
-
|
|
|
- initPluginTabs() {
|
|
|
- const tabs = document.querySelectorAll('.plugin-tab');
|
|
|
- const contents = document.querySelectorAll('.tab-content');
|
|
|
-
|
|
|
- tabs.forEach(tab => {
|
|
|
- tab.addEventListener('click', () => {
|
|
|
- const targetTab = tab.dataset.tab;
|
|
|
-
|
|
|
- // 切换标签激活状态
|
|
|
- tabs.forEach(t => t.classList.remove('active'));
|
|
|
- tab.classList.add('active');
|
|
|
-
|
|
|
- // 切换内容显示
|
|
|
- contents.forEach(content => {
|
|
|
- if (content.dataset.content === targetTab) {
|
|
|
- content.classList.add('active');
|
|
|
- } else {
|
|
|
- content.classList.remove('active');
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // 如果切换到导入/分享标签,更新插件列表
|
|
|
- if (targetTab === 'import') {
|
|
|
- this.updatePluginSelects();
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- initPluginShare() {
|
|
|
- // 使用事件委托,在document上监听点击事件
|
|
|
- document.addEventListener('click', (e) => {
|
|
|
- const target = e.target;
|
|
|
-
|
|
|
- // 生成分享码
|
|
|
- if (target.id === 'generateShareCode') {
|
|
|
- console.log('generateShareCode clicked');
|
|
|
- e.preventDefault();
|
|
|
- this.generateShareCode();
|
|
|
- }
|
|
|
-
|
|
|
- // 复制分享码
|
|
|
- if (target.id === 'copyShareCode') {
|
|
|
- console.log('copyShareCode clicked');
|
|
|
- e.preventDefault();
|
|
|
- this.copyShareCode();
|
|
|
- }
|
|
|
-
|
|
|
- // 导入插件
|
|
|
- if (target.id === 'importPlugin') {
|
|
|
- console.log('importPlugin clicked');
|
|
|
- e.preventDefault();
|
|
|
- this.importPlugin();
|
|
|
- }
|
|
|
-
|
|
|
- // 删除插件
|
|
|
- if (target.id === 'deletePlugin') {
|
|
|
- console.log('deletePlugin clicked');
|
|
|
- e.preventDefault();
|
|
|
- this.deletePluginConfirm();
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- updatePluginSelects() {
|
|
|
- const shareSelect = document.getElementById('sharePluginSelect');
|
|
|
- const deleteSelect = document.getElementById('deletePluginSelect');
|
|
|
-
|
|
|
- if (shareSelect) {
|
|
|
- shareSelect.innerHTML = '<option value="">选择插件...</option>';
|
|
|
- this.plugins.forEach((plugin, index) => {
|
|
|
- const option = document.createElement('option');
|
|
|
- option.value = index;
|
|
|
- option.textContent = `${plugin.icon} ${plugin.name}`;
|
|
|
- shareSelect.appendChild(option);
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- if (deleteSelect) {
|
|
|
- deleteSelect.innerHTML = '<option value="">选择要删除的插件...</option>';
|
|
|
- this.plugins.forEach((plugin, index) => {
|
|
|
- const option = document.createElement('option');
|
|
|
- option.value = index;
|
|
|
- option.textContent = `${plugin.icon} ${plugin.name}`;
|
|
|
- deleteSelect.appendChild(option);
|
|
|
- });
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- generateShareCode() {
|
|
|
- const select = document.getElementById('sharePluginSelect');
|
|
|
- const output = document.getElementById('shareCodeOutput');
|
|
|
- const copyBtn = document.getElementById('copyShareCode');
|
|
|
-
|
|
|
- if (!select || !output) return;
|
|
|
-
|
|
|
- const index = parseInt(select.value);
|
|
|
- if (isNaN(index) || index < 0 || index >= this.plugins.length) {
|
|
|
- this.addOutput('❌ 请先选择一个插件', 'error');
|
|
|
- this.scrollToBottom();
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const plugin = this.plugins[index];
|
|
|
- const shareCode = JSON.stringify(plugin, null, 2);
|
|
|
-
|
|
|
- output.value = shareCode;
|
|
|
- if (copyBtn) copyBtn.style.display = 'block';
|
|
|
-
|
|
|
- this.addOutput(`✅ 已生成 "${plugin.name}" 的分享码`, 'help');
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- copyShareCode() {
|
|
|
- const output = document.getElementById('shareCodeOutput');
|
|
|
- if (!output || !output.value) return;
|
|
|
-
|
|
|
- output.select();
|
|
|
- document.execCommand('copy');
|
|
|
-
|
|
|
- this.addOutput('📋 分享码已复制到剪贴板', 'help');
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- importPlugin() {
|
|
|
- const input = document.getElementById('importCodeInput');
|
|
|
- if (!input || !input.value.trim()) {
|
|
|
- this.addOutput('❌ 请粘贴插件代码', 'error');
|
|
|
- this.scrollToBottom();
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- const pluginData = JSON.parse(input.value);
|
|
|
-
|
|
|
- // 验证插件格式
|
|
|
- if (!pluginData.id || !pluginData.name || !pluginData.jsCode) {
|
|
|
- throw new Error('插件格式不正确,缺少必要字段');
|
|
|
- }
|
|
|
-
|
|
|
- // 调用 Android 接口保存插件
|
|
|
- try {
|
|
|
- const result = Android.savePlugin(JSON.stringify(pluginData));
|
|
|
-
|
|
|
- // 重新加载插件
|
|
|
- this.loadPlugins();
|
|
|
- this.updatePluginSelects();
|
|
|
-
|
|
|
- // 刷新插件列表显示
|
|
|
- this.refreshPluginList();
|
|
|
-
|
|
|
- // 显示成功消息
|
|
|
- this.addOutput(`✅ ${result}`, 'help');
|
|
|
- this.addOutput(`📦 插件 "${pluginData.name}" 已添加到列表`, 'help');
|
|
|
-
|
|
|
- // 清空输入
|
|
|
- input.value = '';
|
|
|
- } catch (e) {
|
|
|
- this.addOutput(`❌ 保存失败: ${e.message}`, 'error');
|
|
|
- }
|
|
|
-
|
|
|
- this.scrollToBottom();
|
|
|
- } catch (e) {
|
|
|
- this.addOutput(`❌ 解析失败: ${e.message}`, 'error');
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- deletePluginConfirm() {
|
|
|
- const select = document.getElementById('deletePluginSelect');
|
|
|
- if (!select) {
|
|
|
- console.error('deletePluginSelect element not found');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- console.log('Select value:', select.value);
|
|
|
- console.log('Plugins length:', this.plugins.length);
|
|
|
-
|
|
|
- const index = parseInt(select.value);
|
|
|
- if (isNaN(index) || index < 0 || index >= this.plugins.length) {
|
|
|
- this.addOutput('❌ 请先选择要删除的插件', 'error');
|
|
|
- this.scrollToBottom();
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const plugin = this.plugins[index];
|
|
|
- console.log('Deleting plugin:', plugin.name, 'ID:', plugin.id);
|
|
|
-
|
|
|
- // 使用自定义确认对话框或直接删除
|
|
|
- const confirmMessage = `确定要删除插件 "${plugin.name}" 吗?`;
|
|
|
- console.log('Showing confirm dialog:', confirmMessage);
|
|
|
-
|
|
|
- let userConfirmed = false;
|
|
|
- try {
|
|
|
- userConfirmed = confirm(confirmMessage);
|
|
|
- console.log('User confirmed:', userConfirmed);
|
|
|
- } catch (e) {
|
|
|
- console.error('Confirm dialog error:', e);
|
|
|
- // 如果confirm失败,直接删除
|
|
|
- userConfirmed = true;
|
|
|
- }
|
|
|
-
|
|
|
- if (userConfirmed) {
|
|
|
- console.log('Proceeding with deletion...');
|
|
|
- try {
|
|
|
- const result = Android.deletePlugin(plugin.id);
|
|
|
- console.log('Delete result:', result);
|
|
|
- this.addOutput(`✅ ${result}`, 'help');
|
|
|
-
|
|
|
- // 重新加载插件
|
|
|
- this.loadPlugins();
|
|
|
- this.updatePluginSelects();
|
|
|
-
|
|
|
- // 刷新插件列表显示
|
|
|
- this.refreshPluginList();
|
|
|
- } catch (e) {
|
|
|
- console.error('Delete error:', e);
|
|
|
- this.addOutput(`❌ 删除失败: ${e.message}`, 'error');
|
|
|
- }
|
|
|
-
|
|
|
- this.scrollToBottom();
|
|
|
- } else {
|
|
|
- console.log('User cancelled deletion');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- refreshPluginList() {
|
|
|
- const list = document.getElementById('pluginList');
|
|
|
- console.log('refreshPluginList called, list element:', list);
|
|
|
- console.log('Current plugins count:', this.plugins.length);
|
|
|
-
|
|
|
- if (!list) return;
|
|
|
-
|
|
|
- list.innerHTML = '';
|
|
|
-
|
|
|
- if (this.plugins.length === 0) {
|
|
|
- console.log('No plugins, showing empty message');
|
|
|
- list.innerHTML = '<div class="no-plugins">暂无插件</div>';
|
|
|
- } else {
|
|
|
- console.log('Rendering', this.plugins.length, 'plugins');
|
|
|
- this.plugins.forEach(plugin => {
|
|
|
- const item = document.createElement('div');
|
|
|
- item.className = 'plugin-item';
|
|
|
- const commandInfo = plugin.command ? `<div class="plugin-command">命令: ${plugin.command}</div>` : '';
|
|
|
- item.innerHTML = `
|
|
|
- <div class="plugin-icon">${plugin.icon}</div>
|
|
|
- <div class="plugin-info">
|
|
|
- <div class="plugin-name">${plugin.name}</div>
|
|
|
- <div class="plugin-desc">${plugin.description}</div>
|
|
|
- ${commandInfo}
|
|
|
- </div>
|
|
|
- `;
|
|
|
- item.addEventListener('click', () => this.openPlugin(plugin));
|
|
|
- list.appendChild(item);
|
|
|
- });
|
|
|
- console.log('Finished rendering plugins, list children:', list.children.length);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- insertText(text) {
|
|
|
- const start = this.input.selectionStart;
|
|
|
- const end = this.input.selectionEnd;
|
|
|
- const value = this.input.value;
|
|
|
-
|
|
|
- // 插入文本
|
|
|
- this.input.value = value.substring(0, start) + text + value.substring(end);
|
|
|
-
|
|
|
- // 设置光标位置
|
|
|
- const newPos = start + text.length;
|
|
|
- this.input.setSelectionRange(newPos, newPos);
|
|
|
-
|
|
|
- // 触发输入事件
|
|
|
- this.handleInput();
|
|
|
- }
|
|
|
-
|
|
|
- handleKeyDown(e) {
|
|
|
- if (e.key === 'Enter') {
|
|
|
- e.preventDefault();
|
|
|
- this.hideCompletionList();
|
|
|
- this.evaluate();
|
|
|
- } else if (e.key === 'ArrowUp') {
|
|
|
- e.preventDefault();
|
|
|
- this.hideCompletionList();
|
|
|
- this.navigateHistory(-1);
|
|
|
- } else if (e.key === 'ArrowDown') {
|
|
|
- e.preventDefault();
|
|
|
- this.hideCompletionList();
|
|
|
- this.navigateHistory(1);
|
|
|
- } else if (e.key === 'Escape') {
|
|
|
- this.input.value = '';
|
|
|
- this.completions = [];
|
|
|
- this.completionIndex = -1;
|
|
|
- this.hideCompletionList();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- handleInput() {
|
|
|
- // 更新自动补全列表
|
|
|
- console.log('handleInput called, value:', this.input.value);
|
|
|
- this.updateCompletionList();
|
|
|
- }
|
|
|
-
|
|
|
- showWelcome() {
|
|
|
- const line = document.createElement('div');
|
|
|
- line.className = 'terminal-line';
|
|
|
- line.innerHTML = `<div class="welcome-message">
|
|
|
- <h2>🧮 某人的计算器 v1.3</h2>
|
|
|
- <p>基于 Symja 的交互式符号计算控制台</p>
|
|
|
- <p><strong>快速开始:</strong></p>
|
|
|
- <p><code>2+2</code> 算术运算</p>
|
|
|
- <p><code>D[x^3, x]</code> 求导</p>
|
|
|
- <p><code>Integrate[x^2, {x,0,1}]</code> 定积分</p>
|
|
|
- <p><code>Solve[x^2-5*x+6==0, x]</code> 解方程</p>
|
|
|
- <p><code>Plot[Sin[x], {x,0,2*Pi}]</code> 函数绘图</p>
|
|
|
- <p style="margin-top:12px">输入 <code>help</code> 查看完整文档 · 输入 <code>plugins</code> 管理插件</p>
|
|
|
- </div>`;
|
|
|
- this.output.appendChild(line);
|
|
|
- }
|
|
|
-
|
|
|
- evaluate() {
|
|
|
- let expression = this.input.value.trim();
|
|
|
-
|
|
|
- if (!expression) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 处理特殊命令
|
|
|
- if (this.handleSpecialCommand(expression)) {
|
|
|
- this.addToHistory(expression);
|
|
|
- this.input.value = '';
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 添加到历史记录
|
|
|
- this.addToHistory(expression);
|
|
|
-
|
|
|
- // 显示输入(带计数器)
|
|
|
- this.addOutput(expression, 'input', { count: this.executionCount });
|
|
|
-
|
|
|
- // 调用Android接口进行计算
|
|
|
- try {
|
|
|
- const result = Android.evaluatePlot(expression);
|
|
|
-
|
|
|
- // 检测是否是 Graphics/绘图结果
|
|
|
- if (typeof result === 'string' && result.startsWith('{"type":"plot"')) {
|
|
|
- this.addPlotOutput(result, this.executionCount);
|
|
|
- }
|
|
|
- // LaTeX 渲染
|
|
|
- else if (result.startsWith('##TEXFORM##')) {
|
|
|
- const latex = result.substring(11);
|
|
|
- this.addOutput(latex, 'result', { renderLatex: true, count: this.executionCount });
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
- // 普通文本结果
|
|
|
- else {
|
|
|
- this.addOutput(result, 'result', { count: this.executionCount });
|
|
|
- }
|
|
|
-
|
|
|
- // 追踪变量
|
|
|
- this.trackVariables(expression);
|
|
|
- } catch (e) {
|
|
|
- this.addOutput('错误: 无法连接到计算引擎', 'error');
|
|
|
- }
|
|
|
-
|
|
|
- // 增加计数器
|
|
|
- this.executionCount++;
|
|
|
- this.saveExecutionCount();
|
|
|
- this.updatePrompt();
|
|
|
-
|
|
|
- // 清空输入框
|
|
|
- this.input.value = '';
|
|
|
- this.completions = [];
|
|
|
- this.completionIndex = -1;
|
|
|
-
|
|
|
- // 滚动到底部
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- addPlotOutput(plotJson, count, labels) {
|
|
|
- const data = JSON.parse(plotJson);
|
|
|
- const line = document.createElement('div');
|
|
|
- line.className = 'terminal-line';
|
|
|
-
|
|
|
- const container = document.createElement('div');
|
|
|
- container.className = 'plot-container';
|
|
|
-
|
|
|
- const canvas = document.createElement('canvas');
|
|
|
- canvas.className = 'plot-canvas';
|
|
|
- const dpr = window.devicePixelRatio || 1;
|
|
|
- canvas.width = 500 * dpr;
|
|
|
- canvas.height = 500 * dpr;
|
|
|
- canvas.style.width = '100%';
|
|
|
- canvas.style.height = '100%';
|
|
|
- container.appendChild(canvas);
|
|
|
-
|
|
|
- // 长按保存(单指静止800ms触发)
|
|
|
- let longPressTimer;
|
|
|
- canvas.addEventListener('touchstart', (e) => {
|
|
|
- clearTimeout(longPressTimer);
|
|
|
- if (e.touches.length === 1) {
|
|
|
- longPressTimer = setTimeout(() => this.savePlot(canvas), 800);
|
|
|
- }
|
|
|
- });
|
|
|
- canvas.addEventListener('touchend', () => clearTimeout(longPressTimer));
|
|
|
- canvas.addEventListener('touchmove', () => clearTimeout(longPressTimer));
|
|
|
-
|
|
|
- line.appendChild(container);
|
|
|
- this.output.appendChild(line);
|
|
|
-
|
|
|
- if (!this.plotRenderer) {
|
|
|
- this.plotRenderer = new PlotRenderer();
|
|
|
- }
|
|
|
- this.plotRenderer.render(canvas, data);
|
|
|
- }
|
|
|
-
|
|
|
- buildLegend(data, labels) {
|
|
|
- const div = document.createElement('div');
|
|
|
- div.className = 'plot-legend';
|
|
|
- const colors = [
|
|
|
- [0.369,0.507,0.710],[0.851,0.208,0.125],[0.267,0.612,0.329],
|
|
|
- [0.737,0.349,0.800],[1.0,0.608,0.0]
|
|
|
- ];
|
|
|
- data.elements.forEach((el, i) => {
|
|
|
- const c = el.color || colors[i % colors.length];
|
|
|
- const rgb = `rgb(${Math.round(c[0]*255)},${Math.round(c[1]*255)},${Math.round(c[2]*255)})`;
|
|
|
- const item = document.createElement('span');
|
|
|
- item.className = 'legend-item';
|
|
|
- item.innerHTML = `<span class="legend-swatch" style="background:${rgb}"></span>${labels ? labels[i] : '曲线'+(i+1)}`;
|
|
|
- div.appendChild(item);
|
|
|
- });
|
|
|
- return div;
|
|
|
- }
|
|
|
-
|
|
|
- savePlot(canvas) {
|
|
|
- try {
|
|
|
- const dataUrl = canvas.toDataURL('image/png');
|
|
|
- const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
|
|
- const filename = 'IJS_Plot_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
|
|
|
- const result = Android.savePlotImage(base64, filename);
|
|
|
- this.addOutput(result, 'help');
|
|
|
- this.scrollToBottom();
|
|
|
- } catch (e) {
|
|
|
- this.addOutput('保存失败: ' + e.message, 'error');
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- handleSpecialCommand(cmd) {
|
|
|
- const lower = cmd.toLowerCase();
|
|
|
-
|
|
|
- if (lower === 'help' || lower === '?') {
|
|
|
- this.showHelp();
|
|
|
- return true;
|
|
|
- }
|
|
|
- if (lower === 'clear' || lower === 'cls') {
|
|
|
- this.clear();
|
|
|
- return true;
|
|
|
- }
|
|
|
- if (lower === 'history') {
|
|
|
- this.showHistory();
|
|
|
- return true;
|
|
|
- }
|
|
|
- if (lower === 'plugins') {
|
|
|
- this.showPluginPanel();
|
|
|
- return true;
|
|
|
- }
|
|
|
- if (lower === 'mcp') {
|
|
|
- this.showMcpAddress();
|
|
|
- return true;
|
|
|
- }
|
|
|
- // 调试:通过MCP evaluate("__open plugin_id")打开全屏插件
|
|
|
- if (lower.startsWith('__open ')) {
|
|
|
- var pid = lower.substring(7).trim();
|
|
|
- var pl = this.plugins.find(function(p) { return p.id === pid || p.command === pid; });
|
|
|
- if (pl) this.openPlugin(pl);
|
|
|
- else this.addOutput('插件未找到: ' + pid, 'error');
|
|
|
- return true;
|
|
|
- }
|
|
|
- if (lower === 'feedback') {
|
|
|
- this.showFeedback();
|
|
|
- return true;
|
|
|
- }
|
|
|
- if (lower.startsWith('clear ')) {
|
|
|
- const varName = cmd.substring(6).trim();
|
|
|
- this.clearVariable(varName);
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- // 检查是否是插件命令
|
|
|
- if (this.handlePluginCommand(lower)) {
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- handlePluginCommand(cmd) {
|
|
|
- // 检查是否是带参数的插件命令格式: command[arg1, arg2, ...]
|
|
|
- const paramMatch = cmd.match(/^([a-z0-9_-]+)\[(.*)\]$/i);
|
|
|
-
|
|
|
- if (paramMatch) {
|
|
|
- const commandName = paramMatch[1].toLowerCase();
|
|
|
- const argsString = paramMatch[2];
|
|
|
-
|
|
|
- // 查找匹配的插件
|
|
|
- const plugin = this.plugins.find(p => p.command && p.command.toLowerCase() === commandName);
|
|
|
-
|
|
|
- if (plugin) {
|
|
|
- // 解析参数
|
|
|
- const args = this.parsePluginArgs(argsString);
|
|
|
-
|
|
|
- // 验证参数数量
|
|
|
- if (args.length !== plugin.inputs.length) {
|
|
|
- this.addOutput(`❌ 参数数量不匹配。${plugin.name} 需要 ${plugin.inputs.length} 个参数,但提供了 ${args.length} 个`, 'error');
|
|
|
- this.scrollToBottom();
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- // 执行插件(直接模式)
|
|
|
- this.executePluginDirect(plugin, args);
|
|
|
- return true;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 查找匹配的插件命令(无参数,打开对话框)
|
|
|
- for (const plugin of this.plugins) {
|
|
|
- if (plugin.command && plugin.command.toLowerCase() === cmd) {
|
|
|
- this.addOutput(`🔌 打开插件: ${plugin.name}`, 'help');
|
|
|
- this.scrollToBottom();
|
|
|
- this.openPlugin(plugin);
|
|
|
- return true;
|
|
|
- }
|
|
|
- }
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- parsePluginArgs(argsString) {
|
|
|
- // 解析逗号分隔的参数,支持引号、大括号、方括号内的逗号
|
|
|
- const args = [];
|
|
|
- let current = '';
|
|
|
- let inQuotes = false;
|
|
|
- let quoteChar = '';
|
|
|
- let bracketDepth = 0; // 跟踪 {} 的嵌套深度
|
|
|
- let squareBracketDepth = 0; // 跟踪 [] 的嵌套深度
|
|
|
-
|
|
|
- for (let i = 0; i < argsString.length; i++) {
|
|
|
- const char = argsString[i];
|
|
|
-
|
|
|
- // 处理引号
|
|
|
- if ((char === '"' || char === "'") && (i === 0 || argsString[i-1] !== '\\')) {
|
|
|
- if (!inQuotes) {
|
|
|
- inQuotes = true;
|
|
|
- quoteChar = char;
|
|
|
- } else if (char === quoteChar) {
|
|
|
- inQuotes = false;
|
|
|
- quoteChar = '';
|
|
|
- }
|
|
|
- current += char;
|
|
|
- }
|
|
|
- // 处理大括号
|
|
|
- else if (char === '{' && !inQuotes) {
|
|
|
- bracketDepth++;
|
|
|
- current += char;
|
|
|
- }
|
|
|
- else if (char === '}' && !inQuotes) {
|
|
|
- bracketDepth--;
|
|
|
- current += char;
|
|
|
- }
|
|
|
- // 处理方括号
|
|
|
- else if (char === '[' && !inQuotes) {
|
|
|
- squareBracketDepth++;
|
|
|
- current += char;
|
|
|
- }
|
|
|
- else if (char === ']' && !inQuotes) {
|
|
|
- squareBracketDepth--;
|
|
|
- current += char;
|
|
|
- }
|
|
|
- // 处理逗号分隔符
|
|
|
- else if (char === ',' && !inQuotes && bracketDepth === 0 && squareBracketDepth === 0) {
|
|
|
- args.push(current.trim().replace(/^["']|["']$/g, ''));
|
|
|
- current = '';
|
|
|
- }
|
|
|
- else {
|
|
|
- current += char;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (current.trim()) {
|
|
|
- args.push(current.trim().replace(/^["']|["']$/g, ''));
|
|
|
- }
|
|
|
-
|
|
|
- return args;
|
|
|
- }
|
|
|
-
|
|
|
- executePluginDirect(plugin, args) {
|
|
|
- try {
|
|
|
- console.log('executePluginDirect called with args:', args);
|
|
|
-
|
|
|
- // 构建输入对象,并转换为 Symja 可运算类型
|
|
|
- const inputValues = {};
|
|
|
- plugin.inputs.forEach((input, index) => {
|
|
|
- const originalArg = args[index];
|
|
|
- const convertedArg = this.convertInputToSymjaType(originalArg);
|
|
|
- console.log(`Converting arg[${index}]: "${originalArg}" -> "${convertedArg}"`);
|
|
|
- inputValues[input.name] = convertedArg;
|
|
|
- });
|
|
|
-
|
|
|
- console.log('Final inputValues:', inputValues);
|
|
|
-
|
|
|
- // 用于存储插件的控制台输出
|
|
|
- const pluginOutputs = [];
|
|
|
-
|
|
|
- const ijsAPI = this.createIJSAPI(pluginOutputs);
|
|
|
- const func = new Function('inputs', 'IJS', plugin.jsCode + '\nreturn calculate(inputs, IJS);');
|
|
|
- const result = func(inputValues, ijsAPI);
|
|
|
-
|
|
|
- console.log('Plugin result:', result);
|
|
|
-
|
|
|
- // 验证返回值
|
|
|
- if (typeof result !== 'string' || result.trim() === '') {
|
|
|
- throw new Error('插件必须返回非空字符串类型的 Symja 表达式');
|
|
|
- }
|
|
|
-
|
|
|
- // 验证返回值是否是有效的 Symja 表达式
|
|
|
- try {
|
|
|
- Android.symjaEval(result);
|
|
|
- } catch (e) {
|
|
|
- throw new Error('插件返回值必须是有效的 Symja 表达式,当前返回: ' + result);
|
|
|
- }
|
|
|
-
|
|
|
- // 显示插件名称和参数
|
|
|
- const argsDisplay = args.map((arg, i) => `${plugin.inputs[i].name}=${arg}`).join(', ');
|
|
|
- this.addOutput(`${plugin.icon} ${plugin.name}[${argsDisplay}]`, 'input', { count: this.executionCount });
|
|
|
-
|
|
|
- // 显示插件的控制台输出(如果有)
|
|
|
- if (pluginOutputs.length > 0) {
|
|
|
- pluginOutputs.forEach(output => {
|
|
|
- this.addOutput(output, 'plugin-info');
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // 显示插件的返回值(可运算结果)
|
|
|
- this.addOutput(result, 'result', { count: this.executionCount });
|
|
|
-
|
|
|
- // 将插件结果保存到 Symja 引擎,使其可以通过 % 引用
|
|
|
- try {
|
|
|
- Android.evaluate(`Out = ${result}`);
|
|
|
- } catch (e) {
|
|
|
- console.log('Plugin result storage error:', e);
|
|
|
- }
|
|
|
-
|
|
|
- // 增加计数器
|
|
|
- this.executionCount++;
|
|
|
- this.saveExecutionCount();
|
|
|
- this.updatePrompt();
|
|
|
-
|
|
|
- this.scrollToBottom();
|
|
|
- } catch (e) {
|
|
|
- console.log('Plugin execution error:', e);
|
|
|
- this.addOutput(`插件执行错误: ${e.message}`, 'error');
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 将用户输入转换为 Symja 可运算的类型
|
|
|
- convertInputToSymjaType(value) {
|
|
|
- console.log(`convertInputToSymjaType called with: "${value}"`);
|
|
|
-
|
|
|
- if (!value || typeof value !== 'string') {
|
|
|
- console.log('Value is not a string, returning as-is');
|
|
|
- return value;
|
|
|
- }
|
|
|
-
|
|
|
- const trimmed = value.trim();
|
|
|
-
|
|
|
- // 空值直接返回
|
|
|
- if (trimmed === '') {
|
|
|
- console.log('Value is empty, returning as-is');
|
|
|
- return trimmed;
|
|
|
- }
|
|
|
-
|
|
|
- // 检查是否是单个变量名(字母开头,后跟字母数字下划线)或 % 符号
|
|
|
- const varNameMatch = trimmed.match(/^([a-zA-Z]\w*|%)$/);
|
|
|
- console.log(`Variable name match result:`, varNameMatch);
|
|
|
-
|
|
|
- if (varNameMatch) {
|
|
|
- // 直接尝试求值,如果是已定义的变量会返回值,否则返回变量名本身
|
|
|
- try {
|
|
|
- const evalResult = Android.symjaEval(trimmed);
|
|
|
- console.log(`Evaluated "${trimmed}" to: "${evalResult}"`);
|
|
|
-
|
|
|
- // 如果求值结果与输入不同,说明变量已定义
|
|
|
- if (evalResult !== trimmed) {
|
|
|
- console.log(`Variable ${trimmed} is defined, using value: ${evalResult}`);
|
|
|
- return evalResult;
|
|
|
- } else {
|
|
|
- console.log(`Variable ${trimmed} is not defined or equals itself`);
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- console.error(`Failed to evaluate ${trimmed}:`, e);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 尝试通过 Symja 验证,如果能成功解析说明是有效的表达式
|
|
|
- console.log(`Trying to validate as Symja expression: "${trimmed}"`);
|
|
|
- try {
|
|
|
- Android.symjaEval(trimmed);
|
|
|
- console.log(`Valid Symja expression, returning: "${trimmed}"`);
|
|
|
- return trimmed;
|
|
|
- } catch (e) {
|
|
|
- // 如果无法解析为 Symja 表达式,返回原值
|
|
|
- // 插件内部可以根据需要进一步处理
|
|
|
- console.log(`Not a valid Symja expression, returning as literal: "${trimmed}"`);
|
|
|
- return trimmed;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- createIJSAPI(pluginOutputs) {
|
|
|
- const self = this;
|
|
|
- // 闭包引用 — IJS.input.xy() 需要访问 canvas 对象
|
|
|
- const canvasRef = { _canvas: null, _logicalW: 600, _logicalH: 800 };
|
|
|
- return {
|
|
|
- // ── IJS.Symja 数学引擎 ─────────────────────────
|
|
|
- Symja: {
|
|
|
- print: function(message) {
|
|
|
- pluginOutputs.push(String(message));
|
|
|
- },
|
|
|
- eval: function(expression) {
|
|
|
- try { return Android.symjaEval(expression); }
|
|
|
- catch (e) { return '错误: 无法调用 Symja'; }
|
|
|
- },
|
|
|
- differentiate: function(expr, variable) { return this.eval(`D[${expr}, ${variable}]`); },
|
|
|
- integrate: function(expr, variable) { return this.eval(`Integrate[${expr}, ${variable}]`); },
|
|
|
- limit: function(expr, variable, point, direction) {
|
|
|
- try { return Android.limit(expr, variable, point, direction || ''); } catch (e) { return '错误:' + e.message; }
|
|
|
- },
|
|
|
- series: function(expr, variable, point, order) {
|
|
|
- try { return Android.series(expr, variable, point, order || 5); } catch (e) { return '错误:' + e.message; }
|
|
|
- },
|
|
|
- solve: function(equation, variable) { return this.eval(`Solve[${equation}, ${variable}]`); },
|
|
|
- simplify: function(expr) { return this.eval(`Simplify[${expr}]`); },
|
|
|
- expand: function(expr) { return this.eval(`Expand[${expr}]`); },
|
|
|
- factor: function(expr) { return this.eval(`Factor[${expr}]`); },
|
|
|
- apart: function(expr, variable) { try { return Android.apart(expr, variable); } catch (e) { return '错误:' + e.message; } },
|
|
|
- together: function(expr) { try { return Android.together(expr); } catch (e) { return '错误:' + e.message; } },
|
|
|
- trigSimplify: function(expr) { try { return Android.trigSimplify(expr); } catch (e) { return '错误:' + e.message; } },
|
|
|
- sum: function(expr, variable, start, end) {
|
|
|
- try { return Android.sum(expr, variable, String(start), String(end)); } catch (e) { return '错误:' + e.message; }
|
|
|
- },
|
|
|
- product: function(expr, variable, start, end) {
|
|
|
- try { return Android.product(expr, variable, String(start), String(end)); } catch (e) { return '错误:' + e.message; }
|
|
|
- },
|
|
|
- matrix: {
|
|
|
- det: function(matrix) { try { return Android.matrixOp('Det', matrix); } catch (e) { return '错误:' + e.message; } },
|
|
|
- inverse: function(matrix) { try { return Android.matrixOp('Inverse', matrix); } catch (e) { return '错误:' + e.message; } },
|
|
|
- transpose: function(matrix) { try { return Android.matrixOp('Transpose', matrix); } catch (e) { return '错误:' + e.message; } },
|
|
|
- eigenvalues: function(matrix) { try { return Android.matrixOp('Eigenvalues', matrix); } catch (e) { return '错误:' + e.message; } },
|
|
|
- rank: function(matrix) { try { return Android.matrixOp('MatrixRank', matrix); } catch (e) { return '错误:' + e.message; } }
|
|
|
- },
|
|
|
- gcd: function(a, b) { try { return Android.gcd(String(a), String(b)); } catch (e) { return '错误:' + e.message; } },
|
|
|
- lcm: function(a, b) { try { return Android.lcm(String(a), String(b)); } catch (e) { return '错误:' + e.message; } },
|
|
|
- factorInteger: function(n) { try { return Android.factorInteger(String(n)); } catch (e) { return '错误:' + e.message; } },
|
|
|
- isPrime: function(n) { try { return Android.isPrime(String(n)); } catch (e) { return '错误:' + e.message; } },
|
|
|
- fibonacci: function(n) { try { return Android.fibonacci(n); } catch (e) { return '错误:' + e.message; } },
|
|
|
- binomial: function(n, k) { try { return Android.binomial(String(n), String(k)); } catch (e) { return '错误:' + e.message; } },
|
|
|
- numeric: function(expr, precision) {
|
|
|
- try { return Android.numericEval(expr, precision || 16); } catch (e) { return '错误:' + e.message; }
|
|
|
- },
|
|
|
- plot: function(func, variable, xMin, xMax) {
|
|
|
- try { return Android.evaluatePlot('Plot[' + func + ', {' + variable + ', ' + xMin + ', ' + xMax + '}]'); } catch (e) { return 'Error:' + e.message; }
|
|
|
- },
|
|
|
- parametricPlot: function(xf, yf, t, tMin, tMax) {
|
|
|
- try { return Android.evaluatePlot('ParametricPlot[{' + xf + ', ' + yf + '}, {' + t + ', ' + tMin + ', ' + tMax + '}]'); } catch (e) { return 'Error:' + e.message; }
|
|
|
- },
|
|
|
- listPlot: function(points) {
|
|
|
- try { return Android.evaluatePlot('ListPlot[' + points + ']'); } catch (e) { return 'Error:' + e.message; }
|
|
|
- },
|
|
|
- toTeX: function(expr) { try { return Android.toTeX(expr); } catch (e) { return '错误:' + e.message; } },
|
|
|
- renderLatex: function(latex) { symjaConsole.addOutput(latex, 'result', { renderLatex: true }); return '已渲染 LaTeX'; }
|
|
|
- },
|
|
|
-
|
|
|
- // ── IJS.canvas 绘图 ────────────────────────────
|
|
|
- canvas: {
|
|
|
- _ctx: null,
|
|
|
- _canvas: null,
|
|
|
- _frameId: null,
|
|
|
- create: function(w, h) {
|
|
|
- const panel = document.getElementById('fullscreenContent');
|
|
|
- if (!panel) return null;
|
|
|
- panel.innerHTML = '';
|
|
|
- const c = document.createElement('canvas');
|
|
|
- const dpr = window.devicePixelRatio || 1;
|
|
|
- // 自适应:未指定尺寸则填满全屏面板
|
|
|
- const cw = w || panel.clientWidth || window.innerWidth;
|
|
|
- const ch = h || panel.clientHeight || window.innerHeight;
|
|
|
- c.width = cw * dpr;
|
|
|
- c.height = ch * dpr;
|
|
|
- c.style.width = cw + 'px';
|
|
|
- c.style.height = ch + 'px';
|
|
|
- c.style.maxWidth = '100%';
|
|
|
- c.style.maxHeight = '100%';
|
|
|
- this._canvas = c;
|
|
|
- this._ctx = c.getContext('2d');
|
|
|
- this._ctx.scale(dpr, dpr);
|
|
|
- this._logicalW = cw;
|
|
|
- this._logicalH = ch;
|
|
|
- // 同步给 input.xy() 的闭包引用
|
|
|
- canvasRef._canvas = c;
|
|
|
- canvasRef._logicalW = cw;
|
|
|
- canvasRef._logicalH = ch;
|
|
|
- panel.appendChild(c);
|
|
|
- return this._ctx;
|
|
|
- },
|
|
|
- /** 获取逻辑画布宽度(供插件做自适应布局) */
|
|
|
- width: function() { return this._logicalW || 600; },
|
|
|
- /** 获取逻辑画布高度 */
|
|
|
- height: function() { return this._logicalH || 800; },
|
|
|
- getContext: function() { return this._ctx; },
|
|
|
- getSize: function() {
|
|
|
- const dpr = window.devicePixelRatio || 1;
|
|
|
- return this._canvas ? { width: this._canvas.width / dpr, height: this._canvas.height / dpr } : { width: 0, height: 0 };
|
|
|
- },
|
|
|
- clear: function() {
|
|
|
- if (this._ctx && this._canvas) {
|
|
|
- const dpr = window.devicePixelRatio || 1;
|
|
|
- this._ctx.clearRect(0, 0, this._canvas.width / dpr, this._canvas.height / dpr);
|
|
|
- }
|
|
|
- },
|
|
|
- fill: function(color) {
|
|
|
- if (this._ctx && this._canvas) {
|
|
|
- const dpr = window.devicePixelRatio || 1;
|
|
|
- this._ctx.fillStyle = color || '#FFFFFF';
|
|
|
- this._ctx.fillRect(0, 0, this._canvas.width / dpr, this._canvas.height / dpr);
|
|
|
- }
|
|
|
- },
|
|
|
- size: function(w, h) {
|
|
|
- if (this._canvas) {
|
|
|
- this._canvas.width = w;
|
|
|
- this._canvas.height = h;
|
|
|
- this._logicalW = w;
|
|
|
- this._logicalH = h;
|
|
|
- canvasRef._logicalW = w;
|
|
|
- canvasRef._logicalH = h;
|
|
|
- }
|
|
|
- },
|
|
|
- // 动画循环
|
|
|
- loop: function(callback) {
|
|
|
- const selfCanvas = this;
|
|
|
- function frame() {
|
|
|
- selfCanvas._frameId = requestAnimationFrame(frame);
|
|
|
- callback();
|
|
|
- }
|
|
|
- this.stop();
|
|
|
- selfCanvas._frameId = requestAnimationFrame(frame);
|
|
|
- },
|
|
|
- stop: function() {
|
|
|
- if (this._frameId) {
|
|
|
- cancelAnimationFrame(this._frameId);
|
|
|
- this._frameId = null;
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- // ── IJS.input 触摸交互 ──────────────────────────
|
|
|
- input: {
|
|
|
- _callbacks: {},
|
|
|
- onTouchStart: function(cb) { this._callbacks.touchstart = cb; },
|
|
|
- onTouchMove: function(cb) { this._callbacks.touchmove = cb; },
|
|
|
- onTouchEnd: function(cb) { this._callbacks.touchend = cb; this._bind(); },
|
|
|
- /** 模拟触摸(MCP 调试用) */
|
|
|
- _fireTouch: function(x, y) {
|
|
|
- if (this._callbacks.touchend) {
|
|
|
- this._callbacks.touchend({
|
|
|
- changedTouches: [{clientX: x, clientY: y}],
|
|
|
- preventDefault: function(){}
|
|
|
- });
|
|
|
- }
|
|
|
- },
|
|
|
- /** 将触摸事件的 clientX/Y 转换为画布逻辑坐标 */
|
|
|
- xy: function(e) {
|
|
|
- var touch = e.changedTouches ? e.changedTouches[0] : (e.touches ? e.touches[0] : e);
|
|
|
- if (!touch) return {x:0,y:0};
|
|
|
- var c = canvasRef._canvas;
|
|
|
- if (!c) return {x: touch.clientX, y: touch.clientY};
|
|
|
- var r = c.getBoundingClientRect();
|
|
|
- return {
|
|
|
- x: (touch.clientX - r.left) * (canvasRef._logicalW / r.width),
|
|
|
- y: (touch.clientY - r.top) * (canvasRef._logicalH / r.height)
|
|
|
- };
|
|
|
- },
|
|
|
- _bound: false,
|
|
|
- _bind: function() {
|
|
|
- if (this._bound) return;
|
|
|
- this._bound = true;
|
|
|
- const panel = document.getElementById('fullscreenContent');
|
|
|
- if (!panel) return;
|
|
|
- const selfInput = this;
|
|
|
- panel.addEventListener('touchstart', function(e) {
|
|
|
- e.preventDefault();
|
|
|
- if (selfInput._callbacks.touchstart) selfInput._callbacks.touchstart(e);
|
|
|
- }, { passive: false });
|
|
|
- panel.addEventListener('touchmove', function(e) {
|
|
|
- e.preventDefault();
|
|
|
- if (selfInput._callbacks.touchmove) selfInput._callbacks.touchmove(e);
|
|
|
- }, { passive: false });
|
|
|
- panel.addEventListener('touchend', function(e) {
|
|
|
- if (selfInput._callbacks.touchend) selfInput._callbacks.touchend(e);
|
|
|
- });
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- // ── IJS.io 文件读写 (应用数据目录) ────────────────
|
|
|
- io: {
|
|
|
- read: function(filename) {
|
|
|
- try { return Android.ioRead(filename); } catch (e) { return null; }
|
|
|
- },
|
|
|
- write: function(filename, content) {
|
|
|
- try { Android.ioWrite(filename, content); return true; } catch (e) { return false; }
|
|
|
- },
|
|
|
- list: function() {
|
|
|
- try { return JSON.parse(Android.ioList()); } catch (e) { return []; }
|
|
|
- },
|
|
|
- delete: function(filename) {
|
|
|
- try { Android.ioDelete(filename); return true; } catch (e) { return false; }
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- // ── IJS.audio 音效 ──────────────────────────────
|
|
|
- audio: {
|
|
|
- _ctx: null,
|
|
|
- _getCtx: function() {
|
|
|
- if (!this._ctx) this._ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
- return this._ctx;
|
|
|
- },
|
|
|
- beep: function(freq, duration, type) {
|
|
|
- try {
|
|
|
- const ctx = this._getCtx();
|
|
|
- const osc = ctx.createOscillator();
|
|
|
- const gain = ctx.createGain();
|
|
|
- osc.type = type || 'square';
|
|
|
- osc.frequency.value = freq || 440;
|
|
|
- gain.gain.value = 0.15;
|
|
|
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + (duration || 0.2));
|
|
|
- osc.connect(gain);
|
|
|
- gain.connect(ctx.destination);
|
|
|
- osc.start(ctx.currentTime);
|
|
|
- osc.stop(ctx.currentTime + (duration || 0.2));
|
|
|
- } catch(e) {}
|
|
|
- },
|
|
|
- playNote: function(note, octave, duration) {
|
|
|
- const notes = { C: 262, D: 294, E: 330, F: 349, G: 392, A: 440, B: 494 };
|
|
|
- const freq = (notes[note] || 440) * Math.pow(2, (octave || 4) - 4);
|
|
|
- this.beep(freq, duration || 0.2, 'square');
|
|
|
- },
|
|
|
- playMelody: function(melody, tempo) {
|
|
|
- let t = 0;
|
|
|
- const beat = (tempo || 120) / 60;
|
|
|
- const selfAudio = this;
|
|
|
- melody.forEach(function(m) {
|
|
|
- if (m.note === 'R') { t += m.dur / beat; return; }
|
|
|
- setTimeout(function() {
|
|
|
- selfAudio.playNote(m.note, m.octave || 4, m.dur / beat * 0.9);
|
|
|
- }, t * 1000);
|
|
|
- t += m.dur / beat;
|
|
|
- });
|
|
|
- },
|
|
|
- playFile: function(filename) {
|
|
|
- // 暂不支持,留待扩展
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- // ── IJS.ui 面板控制 ─────────────────────────────
|
|
|
- ui: {
|
|
|
- close: function() {
|
|
|
- document.getElementById('fullscreenPanel').classList.remove('show');
|
|
|
- },
|
|
|
- setBackground: function(color) {
|
|
|
- const panel = document.getElementById('fullscreenContent');
|
|
|
- if (panel) panel.style.background = color;
|
|
|
- }
|
|
|
- }
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- trackVariables(expression) {
|
|
|
- // 简单的变量赋值检测
|
|
|
- const match = expression.match(/^([a-zA-Z]\w*)\s*=/);
|
|
|
- if (match) {
|
|
|
- this.variables.add(match[1]);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- clearVariable(varName) {
|
|
|
- try {
|
|
|
- const result = Android.clearVariable(varName);
|
|
|
- this.addOutput(result, 'help');
|
|
|
- this.variables.delete(varName);
|
|
|
- } catch (e) {
|
|
|
- this.addOutput(`❌ 清除失败: ${e.message}`, 'error');
|
|
|
- }
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- showHistory() {
|
|
|
- if (this.history.length === 0) {
|
|
|
- this.addOutput('📜 历史记录为空', 'help');
|
|
|
- } else {
|
|
|
- const recent = this.history.slice(-10).reverse();
|
|
|
- const historyText = recent.map((cmd, i) => `${recent.length - i}. ${cmd}`).join('\n');
|
|
|
- this.addOutput(`📜 最近的命令:\n${historyText}`, 'help');
|
|
|
- }
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- showMcpAddress() {
|
|
|
- try {
|
|
|
- const running = typeof Android !== 'undefined' && Android.isMcpRunning && Android.isMcpRunning();
|
|
|
- const port = '8734';
|
|
|
- const status = running ? '✅ 运行中' : '⭕ 未启动';
|
|
|
- const rikkaConfig = JSON.stringify({
|
|
|
- mcpServers: {
|
|
|
- ijs: {
|
|
|
- type: "streamable-http",
|
|
|
- url: `http://127.0.0.1:${port}/mcp`,
|
|
|
- commonOptions: {
|
|
|
- name: "IJS - Symja 符号计算",
|
|
|
- enable: true,
|
|
|
- headers: []
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }, null, 2);
|
|
|
- this.addOutput(`🌐 MCP 服务器 ${status}\n\n📍 地址: http://127.0.0.1:${port}/mcp\n\n📋 RikkaHub 配置:\n${rikkaConfig}\n\n💻 Claude Desktop 配置:\n{"mcpServers":{"ijs":{"url":"http://127.0.0.1:${port}/mcp"}}}\n\n🔧 adb 转发: adb forward tcp:${port} tcp:${port}`, 'help');
|
|
|
- } catch(e) {
|
|
|
- this.addOutput('🌐 MCP 服务器\n 地址: http://127.0.0.1:8734/mcp', 'help');
|
|
|
- }
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- showFeedback() {
|
|
|
- const email = 'nonvioce@qq.com';
|
|
|
- const feedbackId = 'feedback-' + Date.now();
|
|
|
-
|
|
|
- // 创建带复制按钮的反馈信息
|
|
|
- const line = document.createElement('div');
|
|
|
- line.className = 'terminal-line';
|
|
|
- line.innerHTML = `
|
|
|
- <div class="feedback-line">
|
|
|
- <div class="feedback-icon">📧</div>
|
|
|
- <div class="feedback-content">
|
|
|
- <div class="feedback-title">反馈与建议</div>
|
|
|
- <div class="feedback-text">
|
|
|
- 欢迎通过邮件向我们反馈问题或提出建议!
|
|
|
- </div>
|
|
|
- <div class="feedback-email-container">
|
|
|
- <span class="feedback-email">${email}</span>
|
|
|
- <button class="feedback-copy-btn" onclick="symjaConsole.copyFeedbackEmail('${email}', this)">
|
|
|
- 📋 复制邮箱
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- `;
|
|
|
-
|
|
|
- this.output.appendChild(line);
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- copyFeedbackEmail(email, button) {
|
|
|
- // 创建临时文本区域
|
|
|
- const textarea = document.createElement('textarea');
|
|
|
- textarea.value = email;
|
|
|
- textarea.style.position = 'fixed';
|
|
|
- textarea.style.opacity = '0';
|
|
|
- document.body.appendChild(textarea);
|
|
|
- textarea.select();
|
|
|
-
|
|
|
- try {
|
|
|
- document.execCommand('copy');
|
|
|
- button.textContent = '✅ 已复制';
|
|
|
- button.style.background = '#4CAF50';
|
|
|
- setTimeout(() => {
|
|
|
- button.textContent = '📋 复制邮箱';
|
|
|
- button.style.background = '';
|
|
|
- }, 2000);
|
|
|
- } catch (e) {
|
|
|
- console.error('Copy failed:', e);
|
|
|
- button.textContent = '❌ 复制失败';
|
|
|
- setTimeout(() => {
|
|
|
- button.textContent = '📋 复制邮箱';
|
|
|
- }, 2000);
|
|
|
- }
|
|
|
-
|
|
|
- document.body.removeChild(textarea);
|
|
|
- }
|
|
|
-
|
|
|
- addToHistory(cmd) {
|
|
|
- // 避免重复的连续命令
|
|
|
- if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) {
|
|
|
- this.history.push(cmd);
|
|
|
- // 限制历史记录大小
|
|
|
- if (this.history.length > 100) {
|
|
|
- this.history.shift();
|
|
|
- }
|
|
|
- this.saveHistory();
|
|
|
- }
|
|
|
- this.historyIndex = this.history.length;
|
|
|
- }
|
|
|
-
|
|
|
- loadHistory() {
|
|
|
- try {
|
|
|
- if (typeof Android !== 'undefined' && Android.loadHistory) {
|
|
|
- const saved = Android.loadHistory();
|
|
|
- return saved ? JSON.parse(saved) : [];
|
|
|
- }
|
|
|
- } catch (e) {}
|
|
|
- // 回退到 localStorage
|
|
|
- try {
|
|
|
- const saved = localStorage.getItem('symja_history');
|
|
|
- return saved ? JSON.parse(saved) : [];
|
|
|
- } catch (e) { return []; }
|
|
|
- }
|
|
|
-
|
|
|
- saveHistory() {
|
|
|
- const json = JSON.stringify(this.history);
|
|
|
- try {
|
|
|
- if (typeof Android !== 'undefined' && Android.saveHistory) {
|
|
|
- Android.saveHistory(json);
|
|
|
- }
|
|
|
- } catch (e) {}
|
|
|
- try {
|
|
|
- localStorage.setItem('symja_history', json);
|
|
|
- } catch (e) {}
|
|
|
- }
|
|
|
-
|
|
|
- saveSession(name) {
|
|
|
- try {
|
|
|
- const session = {
|
|
|
- history: this.history,
|
|
|
- variables: Array.from(this.variables),
|
|
|
- timestamp: new Date().toISOString()
|
|
|
- };
|
|
|
- localStorage.setItem(`symja_session_${name}`, JSON.stringify(session));
|
|
|
- this.addOutput(`💾 会话已保存: ${name}`, 'help');
|
|
|
- } catch (e) {
|
|
|
- this.addOutput(`❌ 保存失败: ${e.message}`, 'error');
|
|
|
- }
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- addOutput(text, type, options = {}) {
|
|
|
- const line = document.createElement('div');
|
|
|
- line.className = 'terminal-line';
|
|
|
-
|
|
|
- if (type === 'input') {
|
|
|
- // 命令输入显示(带计数器)
|
|
|
- const count = options.count !== undefined ? options.count : '';
|
|
|
- line.innerHTML = `
|
|
|
- <div class="command-line">
|
|
|
- <span class="command-prompt">[${count}]:</span>
|
|
|
- <span class="command-text">${this.escapeHtml(text)}</span>
|
|
|
- </div>
|
|
|
- `;
|
|
|
- } else if (type === 'result') {
|
|
|
- const isError = text.startsWith('错误:');
|
|
|
-
|
|
|
- // 检查是否需要 LaTeX 渲染
|
|
|
- if (options.renderLatex && !isError) {
|
|
|
- const count = options.count !== undefined ? options.count : '';
|
|
|
- line.innerHTML = `
|
|
|
- <div class="latex-line">
|
|
|
- <div class="result-prompt">[${count}]:</div>
|
|
|
- <div class="latex-content">
|
|
|
- <div class="latex-rendered" id="latex-${Date.now()}"></div>
|
|
|
- <div class="latex-source" style="display: none;">${this.escapeHtml(text)}</div>
|
|
|
- <div class="latex-actions">
|
|
|
- <button class="latex-btn latex-toggle" onclick="symjaConsole.toggleLatexSource(this)">显示源码</button>
|
|
|
- <button class="latex-btn latex-copy" onclick="symjaConsole.copyLatex(this)">复制 LaTeX</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- `;
|
|
|
- this.output.appendChild(line);
|
|
|
-
|
|
|
- // 渲染 LaTeX
|
|
|
- const renderedDiv = line.querySelector('.latex-rendered');
|
|
|
- this.renderLatex(text, renderedDiv);
|
|
|
- } else {
|
|
|
- const className = isError ? 'error-line' : 'result-line';
|
|
|
- const count = options.count !== undefined ? options.count : '';
|
|
|
- line.innerHTML = `
|
|
|
- <div class="${className}">
|
|
|
- <span class="result-prompt">[${count}]:</span>
|
|
|
- <span class="result-text">${this.escapeHtml(text)}</span>
|
|
|
- </div>
|
|
|
- `;
|
|
|
- }
|
|
|
- } else if (type === 'help') {
|
|
|
- line.innerHTML = `<div class="info-line">${this.escapeHtml(text)}</div>`;
|
|
|
- } else if (type === 'error') {
|
|
|
- line.innerHTML = `<div class="error-line">${this.escapeHtml(text)}</div>`;
|
|
|
- } else if (type === 'plugin-info') {
|
|
|
- // 插件的控制台输出(通过 Symja.print() 输出的内容)
|
|
|
- line.innerHTML = `<div class="plugin-info-line">${this.escapeHtml(text)}</div>`;
|
|
|
- } else if (type === 'plugin') {
|
|
|
- // 插件的返回值(可运算结果)
|
|
|
- line.innerHTML = `<div class="plugin-line">${this.escapeHtml(text)}</div>`;
|
|
|
- } else if (type === 'mcp-input') {
|
|
|
- const seq = options.seq || '';
|
|
|
- line.innerHTML = `<div class="mcp-input-line">
|
|
|
- <span class="mcp-badge">🌐 MCP</span>
|
|
|
- <span class="mcp-prompt">[${seq}]:</span>
|
|
|
- <span class="mcp-command-text">${this.escapeHtml(text)}</span>
|
|
|
- </div>`;
|
|
|
- } else if (type === 'mcp-latex') {
|
|
|
- line.innerHTML = `<div class="latex-line">
|
|
|
- <div class="latex-content">
|
|
|
- <div class="latex-rendered" id="latex-mcp-${Date.now()}"></div>
|
|
|
- <div class="latex-source" style="display:none">${this.escapeHtml(text)}</div>
|
|
|
- <div class="latex-actions">
|
|
|
- <button class="latex-btn latex-toggle" onclick="symjaConsole.toggleLatexSource(this)">显示源码</button>
|
|
|
- <button class="latex-btn latex-copy" onclick="symjaConsole.copyLatex(this)">复制 LaTeX</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>`;
|
|
|
- this.output.appendChild(line);
|
|
|
- const renderedDiv = line.querySelector('.latex-rendered');
|
|
|
- this.renderLatex(text, renderedDiv);
|
|
|
- } else if (type === 'mcp-result') {
|
|
|
- line.innerHTML = `<div class="mcp-result-line">
|
|
|
- <span class="mcp-prompt"></span>
|
|
|
- <span class="mcp-result-text">${this.escapeHtml(text)}</span>
|
|
|
- </div>`;
|
|
|
- }
|
|
|
-
|
|
|
- this.output.appendChild(line);
|
|
|
- }
|
|
|
-
|
|
|
- // LaTeX 渲染功能
|
|
|
- renderLatex(latex, element) {
|
|
|
- try {
|
|
|
- // 等待 KaTeX 加载
|
|
|
- if (typeof katex !== 'undefined') {
|
|
|
- // 清理 LaTeX 字符串
|
|
|
- let cleanLatex = latex.trim();
|
|
|
-
|
|
|
- // 如果是 Symja 的 TeXForm 输出,可能需要一些转换
|
|
|
- if (cleanLatex.startsWith('TeXForm:')) {
|
|
|
- cleanLatex = cleanLatex.substring(8).trim();
|
|
|
- }
|
|
|
-
|
|
|
- // 使用 KaTeX 渲染
|
|
|
- katex.render(cleanLatex, element, {
|
|
|
- throwOnError: false,
|
|
|
- displayMode: true,
|
|
|
- output: 'html',
|
|
|
- trust: false,
|
|
|
- strict: false
|
|
|
- });
|
|
|
- } else {
|
|
|
- // KaTeX 未加载,显示格式化的源码
|
|
|
- element.style.fontFamily = 'var(--font-mono)';
|
|
|
- element.style.fontSize = '14px';
|
|
|
- element.style.padding = '12px';
|
|
|
- element.style.background = '#F5F5F5';
|
|
|
- element.style.borderRadius = '4px';
|
|
|
- element.style.overflowX = 'auto';
|
|
|
- element.textContent = latex;
|
|
|
- console.warn('KaTeX not loaded, showing source code');
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- // 渲染失败,显示源码
|
|
|
- element.style.fontFamily = 'var(--font-mono)';
|
|
|
- element.style.fontSize = '14px';
|
|
|
- element.style.padding = '12px';
|
|
|
- element.style.background = '#F5F5F5';
|
|
|
- element.style.borderRadius = '4px';
|
|
|
- element.style.overflowX = 'auto';
|
|
|
- element.textContent = latex;
|
|
|
- console.error('LaTeX rendering error:', e);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 切换显示 LaTeX 源码
|
|
|
- toggleLatexSource(button) {
|
|
|
- const parent = button.closest('.latex-line');
|
|
|
- if (!parent) return;
|
|
|
-
|
|
|
- const source = parent.querySelector('.latex-source');
|
|
|
- if (!source) return;
|
|
|
-
|
|
|
- if (source.style.display === 'none') {
|
|
|
- source.style.display = 'block';
|
|
|
- button.textContent = '隐藏源码';
|
|
|
- } else {
|
|
|
- source.style.display = 'none';
|
|
|
- button.textContent = '显示源码';
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 复制 LaTeX 代码
|
|
|
- copyLatex(button) {
|
|
|
- const parent = button.closest('.latex-line');
|
|
|
- if (!parent) return;
|
|
|
-
|
|
|
- const source = parent.querySelector('.latex-source');
|
|
|
- if (!source) return;
|
|
|
-
|
|
|
- const text = source.textContent;
|
|
|
-
|
|
|
- // 创建临时文本区域
|
|
|
- const textarea = document.createElement('textarea');
|
|
|
- textarea.value = text;
|
|
|
- textarea.style.position = 'fixed';
|
|
|
- textarea.style.opacity = '0';
|
|
|
- document.body.appendChild(textarea);
|
|
|
- textarea.select();
|
|
|
-
|
|
|
- try {
|
|
|
- document.execCommand('copy');
|
|
|
- button.textContent = '已复制!';
|
|
|
- setTimeout(() => {
|
|
|
- button.textContent = '复制 LaTeX';
|
|
|
- }, 2000);
|
|
|
- } catch (e) {
|
|
|
- console.error('Copy failed:', e);
|
|
|
- }
|
|
|
-
|
|
|
- document.body.removeChild(textarea);
|
|
|
- }
|
|
|
-
|
|
|
- showHelp() {
|
|
|
- const helpPage = document.getElementById('helpPage');
|
|
|
- const helpContent = document.getElementById('helpContent');
|
|
|
-
|
|
|
- if (!helpPage || !helpContent) return;
|
|
|
-
|
|
|
- // 隐藏键盘
|
|
|
- this.hideKeyboard();
|
|
|
-
|
|
|
- helpContent.innerHTML = `
|
|
|
- <table class="help-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>说明</th>
|
|
|
- <th>命令</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- <tr><td>求导</td><td>D[x^2, x]</td></tr>
|
|
|
- <tr><td>复合函数求导</td><td>D[Sin[x]*Cos[x], x]</td></tr>
|
|
|
- <tr><td>不定积分</td><td>Integrate[x^2, x]</td></tr>
|
|
|
- <tr><td>定积分</td><td>Integrate[x^2, {x, 0, 1}]</td></tr>
|
|
|
- <tr><td>求极限</td><td>Limit[Sin[x]/x, x -> 0]</td></tr>
|
|
|
- <tr><td>泰勒展开</td><td>Series[Exp[x], {x, 0, 5}]</td></tr>
|
|
|
- <tr><td>求和</td><td>Sum[i^2, {i, 1, 10}]</td></tr>
|
|
|
- <tr><td>求积(阶乘)</td><td>Product[i, {i, 1, 5}]</td></tr>
|
|
|
-
|
|
|
- <tr><td>展开</td><td>Expand[(x+1)^3]</td></tr>
|
|
|
- <tr><td>因式分解</td><td>Factor[x^2-4]</td></tr>
|
|
|
- <tr><td>化简</td><td>Simplify[Sin[x]^2+Cos[x]^2]</td></tr>
|
|
|
- <tr><td>通分</td><td>Together[(1/x)+(1/y)]</td></tr>
|
|
|
- <tr><td>部分分式</td><td>Apart[(x+1)/(x^2-1), x]</td></tr>
|
|
|
- <tr><td>约分</td><td>Cancel[(x^2-1)/(x-1)]</td></tr>
|
|
|
- <tr><td>合并同类项</td><td>Collect[x^2+3*x+2*x^2, x]</td></tr>
|
|
|
-
|
|
|
- <tr><td>解方程</td><td>Solve[x^2-5*x+6==0, x]</td></tr>
|
|
|
- <tr><td>方程组</td><td>Solve[{x+y==1, x-y==3}, {x,y}]</td></tr>
|
|
|
- <tr><td>数值求解</td><td>NSolve[x^3-2*x+1==0, x]</td></tr>
|
|
|
- <tr><td>求根</td><td>Roots[x^2-4==0, x]</td></tr>
|
|
|
-
|
|
|
- <tr><td>行列式</td><td>Det[{{1,2},{3,4}}]</td></tr>
|
|
|
- <tr><td>逆矩阵</td><td>Inverse[{{1,2},{3,4}}]</td></tr>
|
|
|
- <tr><td>转置</td><td>Transpose[{{1,2},{3,4}}]</td></tr>
|
|
|
- <tr><td>特征值</td><td>Eigenvalues[{{1,2},{3,4}}]</td></tr>
|
|
|
- <tr><td>特征向量</td><td>Eigenvectors[{{1,2},{3,4}}]</td></tr>
|
|
|
- <tr><td>矩阵秩</td><td>MatrixRank[{{1,2},{2,4}}]</td></tr>
|
|
|
-
|
|
|
- <tr><td>最大公约数</td><td>GCD[12, 18]</td></tr>
|
|
|
- <tr><td>最小公倍数</td><td>LCM[12, 18]</td></tr>
|
|
|
- <tr><td>质因数分解</td><td>FactorInteger[60]</td></tr>
|
|
|
- <tr><td>判断素数</td><td>PrimeQ[17]</td></tr>
|
|
|
- <tr><td>第 n 个素数</td><td>Prime[10]</td></tr>
|
|
|
- <tr><td>斐波那契数</td><td>Fibonacci[10]</td></tr>
|
|
|
- <tr><td>组合数 C(n,k)</td><td>Binomial[5, 2]</td></tr>
|
|
|
-
|
|
|
- <tr><td>高精度计算(50位)</td><td>N[Pi, 50]</td></tr>
|
|
|
- <tr><td>数值化</td><td>N[Sqrt[2]]</td></tr>
|
|
|
- <tr><td>四舍五入</td><td>Round[3.7]</td></tr>
|
|
|
- <tr><td>向下取整</td><td>Floor[3.7]</td></tr>
|
|
|
- <tr><td>向上取整</td><td>Ceiling[3.2]</td></tr>
|
|
|
-
|
|
|
- <tr><td>三角函数</td><td>Sin[x], Cos[x], Tan[x]</td></tr>
|
|
|
- <tr><td>反三角函数</td><td>ArcSin[x], ArcCos[x], ArcTan[x]</td></tr>
|
|
|
- <tr><td>双曲函数</td><td>Sinh[x], Cosh[x], Tanh[x]</td></tr>
|
|
|
- <tr><td>三角展开</td><td>TrigExpand[Sin[2*x]]</td></tr>
|
|
|
- <tr><td>三角化简</td><td>TrigReduce[Sin[x]^2]</td></tr>
|
|
|
-
|
|
|
- <tr><td>实部</td><td>Re[3+4*I]</td></tr>
|
|
|
- <tr><td>虚部</td><td>Im[3+4*I]</td></tr>
|
|
|
- <tr><td>模</td><td>Abs[3+4*I]</td></tr>
|
|
|
- <tr><td>辐角</td><td>Arg[3+4*I]</td></tr>
|
|
|
- <tr><td>共轭</td><td>Conjugate[3+4*I]</td></tr>
|
|
|
-
|
|
|
- <tr><td>生成表格</td><td>Table[i^2, {i, 1, 10}]</td></tr>
|
|
|
- <tr><td>生成数列</td><td>Range[1, 10]</td></tr>
|
|
|
- <tr><td>带步长的数列</td><td>Range[0, 1, 0.1]</td></tr>
|
|
|
- <tr><td>列表长度</td><td>Length[{1,2,3,4}]</td></tr>
|
|
|
- <tr><td>第一个元素</td><td>First[{1,2,3}]</td></tr>
|
|
|
- <tr><td>最后一个元素</td><td>Last[{1,2,3}]</td></tr>
|
|
|
- <tr><td>取前 n 个</td><td>Take[{1,2,3,4,5}, 3]</td></tr>
|
|
|
- <tr><td>删除前 n 个</td><td>Drop[{1,2,3,4,5}, 2]</td></tr>
|
|
|
- <tr><td>连接列表</td><td>Join[{1,2}, {3,4}]</td></tr>
|
|
|
- <tr><td>反转列表</td><td>Reverse[{1,2,3}]</td></tr>
|
|
|
- <tr><td>排序</td><td>Sort[{3,1,4,1,5}]</td></tr>
|
|
|
-
|
|
|
- <tr><td>替换规则</td><td>b+c /. b->1</td></tr>
|
|
|
- <tr><td>变量替换</td><td>x^2+2*x+1 /. x->3</td></tr>
|
|
|
- <tr><td>多重替换</td><td>expr /. {a->1, b->2}</td></tr>
|
|
|
-
|
|
|
- <tr><td>微分方程</td><td>DSolve[y'[x]==y[x], y[x], x]</td></tr>
|
|
|
- <tr><td>递推方程</td><td>RSolve[a[n]==2*a[n-1]+1, a[n], n]</td></tr>
|
|
|
-
|
|
|
- <tr><td>拉普拉斯变换</td><td>LaplaceTransform[Sin[t], t, s]</td></tr>
|
|
|
- <tr><td>逆拉普拉斯变换</td><td>InverseLaplaceTransform[1/(s^2+1), s, t]</td></tr>
|
|
|
- <tr><td>傅里叶变换</td><td>FourierTransform[Exp[-x^2], x, w]</td></tr>
|
|
|
-
|
|
|
- <tr><td>求最大值</td><td>Maximize[x^2-4*x+3, x]</td></tr>
|
|
|
- <tr><td>求最小值</td><td>Minimize[x^2-4*x+3, x]</td></tr>
|
|
|
-
|
|
|
- <tr><td>平均值</td><td>Mean[{1,2,3,4,5}]</td></tr>
|
|
|
- <tr><td>中位数</td><td>Median[{1,2,3,4,5}]</td></tr>
|
|
|
- <tr><td>方差</td><td>Variance[{1,2,3,4,5}]</td></tr>
|
|
|
- <tr><td>标准差</td><td>StandardDeviation[{1,2,3,4,5}]</td></tr>
|
|
|
-
|
|
|
- <tr><td>LaTeX 格式</td><td>TeXForm[x^2 + 2*x + 1]</td></tr>
|
|
|
- <tr><td>完整形式</td><td>FullForm[x+y]</td></tr>
|
|
|
- <tr><td>输入形式</td><td>InputForm[expr]</td></tr>
|
|
|
-
|
|
|
- <tr><td>显示帮助</td><td>help 或 ?</td></tr>
|
|
|
- <tr><td>清空输出</td><td>clear 或 cls</td></tr>
|
|
|
- <tr><td>命令历史</td><td>history</td></tr>
|
|
|
- <tr><td>清除变量</td><td>clear <变量></td></tr>
|
|
|
- <tr><td>插件管理</td><td>plugins</td></tr>
|
|
|
- <tr><td>显示反馈邮箱</td><td>feedback</td></tr>
|
|
|
-
|
|
|
- <tr><td>圆周率 π</td><td>Pi</td></tr>
|
|
|
- <tr><td>自然常数 e</td><td>E</td></tr>
|
|
|
- <tr><td>虚数单位 i</td><td>I</td></tr>
|
|
|
- <tr><td>无穷大 ∞</td><td>Infinity</td></tr>
|
|
|
- <tr><td>布尔值</td><td>True, False</td></tr>
|
|
|
- <tr><td>上一次的输出结果</td><td>%</td></tr>
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- `;
|
|
|
-
|
|
|
- helpPage.classList.add('show');
|
|
|
- }
|
|
|
-
|
|
|
- hideHelp() {
|
|
|
- const helpPage = document.getElementById('helpPage');
|
|
|
- if (helpPage) {
|
|
|
- helpPage.classList.remove('show');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- showSettings() {
|
|
|
- const panel = document.getElementById('settingsPanel');
|
|
|
- if (!panel) return;
|
|
|
- this.hideKeyboard();
|
|
|
- document.getElementById('settingMcpEnabled').value = this.getMcpEnabled() ? '1' : '0';
|
|
|
- document.getElementById('settingMcpPort').value = this.getSetting('mcpPort', '8734');
|
|
|
- document.getElementById('settingFontSize').value = this.getSetting('fontSize', '14');
|
|
|
- panel.classList.add('show');
|
|
|
- }
|
|
|
-
|
|
|
- hideSettings() {
|
|
|
- document.getElementById('settingsPanel')?.classList.remove('show');
|
|
|
- }
|
|
|
-
|
|
|
- applySettings() {
|
|
|
- const wasEnabled = this.getMcpEnabled();
|
|
|
- const enabled = document.getElementById('settingMcpEnabled').value === '1';
|
|
|
- const port = document.getElementById('settingMcpPort').value;
|
|
|
- const fontSize = document.getElementById('settingFontSize').value;
|
|
|
- this.saveSetting('mcpPort', port);
|
|
|
- this.saveSetting('fontSize', fontSize);
|
|
|
- this.setMcpEnabled(enabled);
|
|
|
- document.querySelector('.terminal-output').style.fontSize = fontSize + 'px';
|
|
|
- document.querySelector('.terminal-input').style.fontSize = fontSize + 'px';
|
|
|
- this.hideSettings();
|
|
|
- this.addOutput('⚙️ 设置已应用', 'help');
|
|
|
- this.scrollToBottom();
|
|
|
-
|
|
|
- // MCP 状态变更时更新轮询
|
|
|
- if (enabled !== wasEnabled) {
|
|
|
- if (enabled) {
|
|
|
- this.startMcpPollingIfNeeded();
|
|
|
- } else {
|
|
|
- this.stopMcpPolling();
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- getMcpEnabled() {
|
|
|
- try {
|
|
|
- if (typeof Android !== 'undefined' && Android.getMcpEnabled !== undefined) {
|
|
|
- return Android.getMcpEnabled();
|
|
|
- }
|
|
|
- } catch(e) {}
|
|
|
- return this.getSetting('mcpEnabled', '1') === '1';
|
|
|
- }
|
|
|
-
|
|
|
- setMcpEnabled(val) {
|
|
|
- this.saveSetting('mcpEnabled', val ? '1' : '0');
|
|
|
- try {
|
|
|
- if (typeof Android !== 'undefined' && Android.setMcpEnabled) {
|
|
|
- Android.setMcpEnabled(val);
|
|
|
- }
|
|
|
- } catch(e) {}
|
|
|
- }
|
|
|
-
|
|
|
- startMcpPollingIfNeeded() {
|
|
|
- if (!this.getMcpEnabled()) return;
|
|
|
- if (!this.mcpPollTimer) {
|
|
|
- this.mcpPollTimer = setInterval(() => this.pollMcpEvents(), 1000);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- stopMcpPolling() {
|
|
|
- if (this.mcpPollTimer) { clearInterval(this.mcpPollTimer); this.mcpPollTimer = null; }
|
|
|
- }
|
|
|
-
|
|
|
- getSetting(key, def) {
|
|
|
- try { return localStorage.getItem('ijs_' + key) || def; } catch(e) { return def; }
|
|
|
- }
|
|
|
-
|
|
|
- saveSetting(key, val) {
|
|
|
- try { localStorage.setItem('ijs_' + key, val); } catch(e) {}
|
|
|
- }
|
|
|
-
|
|
|
- loadSettings() {
|
|
|
- const fs = this.getSetting('fontSize', '14');
|
|
|
- try {
|
|
|
- document.querySelector('.terminal-output').style.fontSize = fs + 'px';
|
|
|
- document.querySelector('.terminal-input').style.fontSize = fs + 'px';
|
|
|
- } catch(e) {}
|
|
|
- }
|
|
|
-
|
|
|
- clear() {
|
|
|
- this.output.innerHTML = '';
|
|
|
- this.showWelcome();
|
|
|
- // 重置计数器
|
|
|
- this.executionCount = 1;
|
|
|
- this.saveExecutionCount();
|
|
|
- this.updatePrompt();
|
|
|
- }
|
|
|
-
|
|
|
- reset() {
|
|
|
- try {
|
|
|
- const result = Android.reset();
|
|
|
- this.variables.clear();
|
|
|
- this.addOutput('🔄 ' + result, 'help');
|
|
|
- this.scrollToBottom();
|
|
|
- // 重置计数器
|
|
|
- this.executionCount = 1;
|
|
|
- this.saveExecutionCount();
|
|
|
- this.updatePrompt();
|
|
|
- } catch (e) {
|
|
|
- this.addOutput('错误: 无法重置计算器', 'error');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- updatePrompt() {
|
|
|
- if (this.inputPrompt) {
|
|
|
- this.inputPrompt.textContent = `[${this.executionCount}]:`;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- loadExecutionCount() {
|
|
|
- return 1; // 每次打开应用从1开始
|
|
|
- }
|
|
|
-
|
|
|
- saveExecutionCount() {
|
|
|
- // 不持久化编号,每次打开都重置
|
|
|
- }
|
|
|
-
|
|
|
- navigateHistory(direction) {
|
|
|
- if (this.history.length === 0) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 保存当前输入
|
|
|
- if (this.historyIndex === this.history.length) {
|
|
|
- this.tempInput = this.input.value;
|
|
|
- }
|
|
|
-
|
|
|
- this.historyIndex += direction;
|
|
|
-
|
|
|
- if (this.historyIndex < 0) {
|
|
|
- this.historyIndex = 0;
|
|
|
- } else if (this.historyIndex >= this.history.length) {
|
|
|
- this.historyIndex = this.history.length;
|
|
|
- this.input.value = this.tempInput;
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- this.input.value = this.history[this.historyIndex];
|
|
|
- // 光标移到末尾
|
|
|
- setTimeout(() => {
|
|
|
- this.input.setSelectionRange(this.input.value.length, this.input.value.length);
|
|
|
- }, 0);
|
|
|
- }
|
|
|
-
|
|
|
- // 自动补全功能
|
|
|
- updateCompletionList() {
|
|
|
- const value = this.input.value;
|
|
|
- const cursorPos = this.input.selectionStart;
|
|
|
- const textBeforeCursor = value.substring(0, cursorPos);
|
|
|
- const match = textBeforeCursor.match(/[A-Za-z0-9_-]+$/);
|
|
|
-
|
|
|
- console.log('updateCompletionList:', { value, textBeforeCursor, match });
|
|
|
-
|
|
|
- if (match && match[0].length >= 2) {
|
|
|
- const prefix = match[0];
|
|
|
- console.log('Searching completions for prefix:', prefix);
|
|
|
-
|
|
|
- // 特殊命令列表
|
|
|
- const specialCommands = ['help', 'clear', 'cls', 'history', 'plugins', 'mcp', 'feedback'];
|
|
|
-
|
|
|
- // 获取插件命令列表
|
|
|
- const pluginCommands = this.plugins
|
|
|
- .filter(p => p.command && p.command.length > 0)
|
|
|
- .map(p => p.command);
|
|
|
-
|
|
|
- try {
|
|
|
- const completionsJson = Android.getCompletions(prefix);
|
|
|
- console.log('Raw completions JSON:', completionsJson);
|
|
|
-
|
|
|
- // 解析补全结果 - 可能是逗号分隔的字符串或 JSON 数组
|
|
|
- let symjaCompletions = [];
|
|
|
- if (completionsJson.startsWith('[')) {
|
|
|
- // JSON 数组格式
|
|
|
- symjaCompletions = JSON.parse(completionsJson);
|
|
|
- } else {
|
|
|
- // 逗号分隔的字符串格式
|
|
|
- symjaCompletions = completionsJson.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
|
- }
|
|
|
-
|
|
|
- // 添加匹配的特殊命令
|
|
|
- const lowerPrefix = prefix.toLowerCase();
|
|
|
- const matchingCommands = specialCommands.filter(cmd => cmd.startsWith(lowerPrefix));
|
|
|
-
|
|
|
- // 添加匹配的插件命令
|
|
|
- const matchingPluginCommands = pluginCommands.filter(cmd => cmd.toLowerCase().startsWith(lowerPrefix));
|
|
|
-
|
|
|
- // 合并并去重(插件命令优先显示)
|
|
|
- this.completions = [...new Set([...matchingPluginCommands, ...matchingCommands, ...symjaCompletions])];
|
|
|
-
|
|
|
- console.log('Parsed completions:', this.completions);
|
|
|
-
|
|
|
- if (this.completions.length > 0) {
|
|
|
- this.showCompletionList();
|
|
|
- } else {
|
|
|
- this.hideCompletionList();
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- console.error('Completion error:', e);
|
|
|
- this.completions = [];
|
|
|
- this.hideCompletionList();
|
|
|
- }
|
|
|
- } else {
|
|
|
- this.completions = [];
|
|
|
- this.hideCompletionList();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- showCompletionList() {
|
|
|
- console.log('showCompletionList called');
|
|
|
- console.log('completionList element:', this.completionList);
|
|
|
- console.log('completions:', this.completions);
|
|
|
-
|
|
|
- if (!this.completionList) {
|
|
|
- console.error('completionList element not found');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (this.completions.length === 0) {
|
|
|
- console.log('No completions to show');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- this.completionList.innerHTML = '';
|
|
|
-
|
|
|
- // 最多显示 8 个补全项
|
|
|
- const itemsToShow = this.completions.slice(0, 8);
|
|
|
- console.log('Items to show:', itemsToShow);
|
|
|
-
|
|
|
- itemsToShow.forEach(completion => {
|
|
|
- const item = document.createElement('div');
|
|
|
- item.className = 'completion-item';
|
|
|
-
|
|
|
- // 检查是否是插件命令
|
|
|
- const plugin = this.plugins.find(p => p.command === completion);
|
|
|
- if (plugin) {
|
|
|
- // 插件命令,显示图标和名称
|
|
|
- item.innerHTML = `<span style="margin-right: 8px;">${plugin.icon}</span>${completion} <span style="color: #999; font-size: 12px; margin-left: 8px;">(${plugin.name})</span>`;
|
|
|
- } else {
|
|
|
- // 普通命令或函数
|
|
|
- item.textContent = completion;
|
|
|
- }
|
|
|
-
|
|
|
- item.addEventListener('click', () => {
|
|
|
- this.applyCompletion(completion);
|
|
|
- });
|
|
|
- this.completionList.appendChild(item);
|
|
|
- });
|
|
|
-
|
|
|
- this.completionList.classList.add('show');
|
|
|
- console.log('Completion list should now be visible');
|
|
|
- }
|
|
|
-
|
|
|
- hideCompletionList() {
|
|
|
- if (this.completionList) {
|
|
|
- this.completionList.classList.remove('show');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- applyCompletion(completion) {
|
|
|
- const value = this.input.value;
|
|
|
- const cursorPos = this.input.selectionStart;
|
|
|
- const textBeforeCursor = value.substring(0, cursorPos);
|
|
|
- const textAfterCursor = value.substring(cursorPos);
|
|
|
-
|
|
|
- const match = textBeforeCursor.match(/[A-Za-z0-9_-]+$/);
|
|
|
- if (match) {
|
|
|
- const prefix = match[0];
|
|
|
- const beforePrefix = textBeforeCursor.substring(0, textBeforeCursor.length - prefix.length);
|
|
|
-
|
|
|
- // 检查是否是插件命令
|
|
|
- const plugin = this.plugins.find(p => p.command === completion);
|
|
|
- if (plugin && plugin.inputs && plugin.inputs.length > 0) {
|
|
|
- // 插件命令,添加参数括号和提示
|
|
|
- const paramHints = plugin.inputs.map(input => input.name).join(', ');
|
|
|
- this.input.value = beforePrefix + completion + '[' + paramHints + ']' + textAfterCursor;
|
|
|
-
|
|
|
- // 将光标放在第一个参数位置
|
|
|
- const newCursorPos = beforePrefix.length + completion.length + 1;
|
|
|
- this.input.setSelectionRange(newCursorPos, newCursorPos + plugin.inputs[0].name.length);
|
|
|
- } else {
|
|
|
- // 普通命令或函数
|
|
|
- this.input.value = beforePrefix + completion + textAfterCursor;
|
|
|
-
|
|
|
- const newCursorPos = beforePrefix.length + completion.length;
|
|
|
- this.input.setSelectionRange(newCursorPos, newCursorPos);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- this.hideCompletionList();
|
|
|
- this.input.focus();
|
|
|
- }
|
|
|
-
|
|
|
- handleAutoComplete() {
|
|
|
- // 保留这个方法以防其他地方调用,但不再使用
|
|
|
- this.updateCompletionList();
|
|
|
- }
|
|
|
-
|
|
|
- // 插件相关功能
|
|
|
- loadPlugins() {
|
|
|
- try {
|
|
|
- const pluginsJson = Android.getPlugins();
|
|
|
- console.log('Loaded plugins JSON:', pluginsJson);
|
|
|
- this.plugins = JSON.parse(pluginsJson);
|
|
|
- console.log('Parsed plugins count:', this.plugins.length);
|
|
|
- console.log('Plugins:', this.plugins);
|
|
|
- } catch (e) {
|
|
|
- console.error('Failed to load plugins:', e);
|
|
|
- this.plugins = [];
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- showPluginPanel() {
|
|
|
- this.loadPlugins();
|
|
|
- const panel = document.getElementById('pluginPanel');
|
|
|
-
|
|
|
- if (!panel) return;
|
|
|
-
|
|
|
- // 隐藏键盘
|
|
|
- this.hideKeyboard();
|
|
|
-
|
|
|
- // 确保默认显示插件列表标签
|
|
|
- const tabs = panel.querySelectorAll('.plugin-tab');
|
|
|
- const contents = panel.querySelectorAll('.tab-content');
|
|
|
-
|
|
|
- tabs.forEach(tab => {
|
|
|
- if (tab.dataset.tab === 'list') {
|
|
|
- tab.classList.add('active');
|
|
|
- } else {
|
|
|
- tab.classList.remove('active');
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- contents.forEach(content => {
|
|
|
- if (content.dataset.content === 'list') {
|
|
|
- content.classList.add('active');
|
|
|
- } else {
|
|
|
- content.classList.remove('active');
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // 更新插件下拉列表(用于分享和删除)
|
|
|
- this.updatePluginSelects();
|
|
|
-
|
|
|
- // 使用统一的刷新方法
|
|
|
- this.refreshPluginList();
|
|
|
-
|
|
|
- panel.classList.add('show');
|
|
|
- }
|
|
|
-
|
|
|
- hidePluginPanel() {
|
|
|
- const panel = document.getElementById('pluginPanel');
|
|
|
- if (panel) panel.classList.remove('show');
|
|
|
- }
|
|
|
-
|
|
|
- openPlugin(plugin) {
|
|
|
- this.currentPlugin = plugin;
|
|
|
- this.hidePluginPanel();
|
|
|
-
|
|
|
- // Fullscreen mode — open the fullscreen panel
|
|
|
- if (plugin.mode === 'fullscreen') {
|
|
|
- this.openFullscreenPlugin(plugin);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // Inline mode — open the standard dialog
|
|
|
- const dialog = document.getElementById('pluginDialog');
|
|
|
- const title = document.getElementById('pluginDialogTitle');
|
|
|
- const desc = document.getElementById('pluginDialogDesc');
|
|
|
- const inputs = document.getElementById('pluginInputs');
|
|
|
-
|
|
|
- if (!dialog || !title || !desc || !inputs) return;
|
|
|
-
|
|
|
- title.textContent = plugin.icon + ' ' + plugin.name;
|
|
|
- desc.textContent = plugin.description;
|
|
|
-
|
|
|
- inputs.innerHTML = '';
|
|
|
- plugin.inputs.forEach(input => {
|
|
|
- const inputGroup = document.createElement('div');
|
|
|
- inputGroup.className = 'input-group';
|
|
|
- inputGroup.innerHTML = `
|
|
|
- <label>${input.label}</label>
|
|
|
- <input type="text"
|
|
|
- name="${input.name}"
|
|
|
- value="${input.defaultValue || ''}"
|
|
|
- placeholder="${input.placeholder || ''}">
|
|
|
- `;
|
|
|
- inputs.appendChild(inputGroup);
|
|
|
- });
|
|
|
-
|
|
|
- dialog.classList.add('show');
|
|
|
- }
|
|
|
-
|
|
|
- hidePluginDialog() {
|
|
|
- const dialog = document.getElementById('pluginDialog');
|
|
|
- if (dialog) dialog.classList.remove('show');
|
|
|
- this.currentPlugin = null;
|
|
|
- }
|
|
|
-
|
|
|
- openFullscreenPlugin(plugin) {
|
|
|
- const panel = document.getElementById('fullscreenPanel');
|
|
|
- document.getElementById('fsPluginTitle').textContent = plugin.icon + ' ' + plugin.name;
|
|
|
- panel.classList.add('show');
|
|
|
-
|
|
|
- // Run the plugin in fullscreen mode
|
|
|
- const pluginOutputs = [];
|
|
|
- const ijsAPI = this.createIJSAPI(pluginOutputs);
|
|
|
- this._lastIjsApi = ijsAPI; // 保存引用供 MCP __tap 调试使用
|
|
|
-
|
|
|
- // Create a default inputs object from plugin defaults
|
|
|
- const inputValues = {};
|
|
|
- if (plugin.inputs) {
|
|
|
- plugin.inputs.forEach(function(inp) {
|
|
|
- inputValues[inp.name] = inp.defaultValue || '';
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // Fullscreen plugins don't return values — they use IJS.canvas/IJS.input
|
|
|
- const func = new Function('inputs', 'IJS', plugin.jsCode + '\ncalculate(inputs, IJS);');
|
|
|
- try {
|
|
|
- func(inputValues, ijsAPI);
|
|
|
- } catch (e) {
|
|
|
- console.error('Fullscreen plugin error:', e);
|
|
|
- ijsAPI.ui.close();
|
|
|
- this.addOutput('插件错误: ' + e.message, 'error');
|
|
|
- this.scrollToBottom();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- executeCurrentPlugin() {
|
|
|
- if (!this.currentPlugin) return;
|
|
|
-
|
|
|
- const inputsDiv = document.getElementById('pluginInputs');
|
|
|
- if (!inputsDiv) return;
|
|
|
-
|
|
|
- const inputElements = inputsDiv.querySelectorAll('input');
|
|
|
- const inputValues = {};
|
|
|
-
|
|
|
- // 收集输入值并转换为 Symja 可运算类型
|
|
|
- inputElements.forEach(input => {
|
|
|
- inputValues[input.name] = this.convertInputToSymjaType(input.value);
|
|
|
- });
|
|
|
-
|
|
|
- try {
|
|
|
- // 用于存储插件的控制台输出
|
|
|
- const pluginOutputs = [];
|
|
|
-
|
|
|
- // 创建 IJS API 对象
|
|
|
- const ijsAPI = this.createIJSAPI(pluginOutputs);
|
|
|
-
|
|
|
- // 执行插件代码
|
|
|
- const func = new Function('inputs', 'IJS', this.currentPlugin.jsCode + '\nreturn calculate(inputs, IJS);');
|
|
|
- const result = func(inputValues, ijsAPI);
|
|
|
-
|
|
|
- // 验证返回值是否为有效的可运算类型
|
|
|
- if (typeof result !== 'string' || result.trim() === '') {
|
|
|
- throw new Error('插件必须返回非空字符串类型的 Symja 表达式');
|
|
|
- }
|
|
|
-
|
|
|
- // 验证返回值是否是有效的 Symja 表达式(尝试计算)
|
|
|
- try {
|
|
|
- Android.symjaEval(result);
|
|
|
- } catch (e) {
|
|
|
- throw new Error('插件返回值必须是有效的 Symja 表达式,当前返回: ' + result);
|
|
|
- }
|
|
|
-
|
|
|
- // 显示插件名称
|
|
|
- this.addOutput(`${this.currentPlugin.icon} ${this.currentPlugin.name}`, 'input');
|
|
|
-
|
|
|
- // 显示插件的控制台输出(如果有)
|
|
|
- if (pluginOutputs.length > 0) {
|
|
|
- pluginOutputs.forEach(output => {
|
|
|
- this.addOutput(output, 'plugin-info');
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // 显示插件的返回值(可运算结果)
|
|
|
- this.addOutput(result, 'plugin');
|
|
|
-
|
|
|
- // 将插件结果保存到 Symja 引擎,使其可以通过 % 引用
|
|
|
- try {
|
|
|
- Android.evaluate(`Out = ${result}`);
|
|
|
- } catch (e) {
|
|
|
- console.log('Plugin result storage error:', e);
|
|
|
- }
|
|
|
-
|
|
|
- this.scrollToBottom();
|
|
|
- this.hidePluginDialog();
|
|
|
- } catch (e) {
|
|
|
- this.addOutput(`插件执行错误: ${e.message}`, 'error');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- scrollToBottom() {
|
|
|
- this.output.scrollTop = this.output.scrollHeight;
|
|
|
- }
|
|
|
-
|
|
|
- escapeHtml(text) {
|
|
|
- const div = document.createElement('div');
|
|
|
- div.textContent = text;
|
|
|
- return div.innerHTML;
|
|
|
- }
|
|
|
-
|
|
|
- hideKeyboard() {
|
|
|
- // 移除输入框焦点
|
|
|
- if (this.input) {
|
|
|
- this.input.blur();
|
|
|
- }
|
|
|
-
|
|
|
- // 调用 Android 接口隐藏键盘
|
|
|
- try {
|
|
|
- if (typeof Android !== 'undefined' && Android.hideKeyboard) {
|
|
|
- Android.hideKeyboard();
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- console.log('hideKeyboard not available:', e);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- pollMcpEvents() {
|
|
|
- try {
|
|
|
- if (typeof Android === 'undefined' || !Android.getMcpEvents) return;
|
|
|
- const json = Android.getMcpEvents();
|
|
|
- if (!json || json === '[]') return;
|
|
|
- const events = JSON.parse(json);
|
|
|
- events.forEach(ev => {
|
|
|
- // 调试命令:MCP 触发打开全屏插件
|
|
|
- if (ev.expression.startsWith('##OPEN_PLUGIN##')) {
|
|
|
- var pid = ev.expression.substring(16);
|
|
|
- var pl = this.plugins.find(function(p) { return p.id === pid || p.command === pid; });
|
|
|
- if (pl) this.openPlugin(pl);
|
|
|
- return;
|
|
|
- }
|
|
|
- // __eval 结果回传
|
|
|
- if (ev.expression === '__eval_result') {
|
|
|
- try {
|
|
|
- var er = JSON.parse(ev.result);
|
|
|
- if (er.ok) {
|
|
|
- this.addOutput('__eval → ' + JSON.stringify(er.result), 'mcp-result');
|
|
|
- } else {
|
|
|
- this.addOutput('__eval error: ' + er.error, 'error');
|
|
|
- }
|
|
|
- } catch(e) {
|
|
|
- this.addOutput('__eval raw: ' + ev.result, 'mcp-result');
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 调试命令:模拟画布点击 __tap x y (逻辑坐标)
|
|
|
- if (ev.expression.startsWith('__tap ')) {
|
|
|
- var parts = ev.expression.split(/\s+/);
|
|
|
- var tx = parseFloat(parts[1]), ty = parseFloat(parts[2]);
|
|
|
- if (!isNaN(tx) && !isNaN(ty)) {
|
|
|
- // 将逻辑坐标转换为屏幕 clientX/Y
|
|
|
- var c = document.querySelector('#fullscreenContent canvas');
|
|
|
- if (c) {
|
|
|
- var r = c.getBoundingClientRect();
|
|
|
- var cx = r.left + (tx / (parseInt(c.style.width) || r.width)) * r.width;
|
|
|
- var cy = r.top + (ty / (parseInt(c.style.height) || r.height)) * r.height;
|
|
|
- // 通过 _fireTouch 直接触发插件回调
|
|
|
- try {
|
|
|
- // 获取当前插件的 IJS API 实例,调用 _fireTouch
|
|
|
- var apiInstance = symjaConsole._lastIjsApi;
|
|
|
- if (apiInstance && apiInstance.input && apiInstance.input._fireTouch) {
|
|
|
- apiInstance.input._fireTouch(cx, cy);
|
|
|
- }
|
|
|
- } catch(e) { console.log('_fireTouch error:', e); }
|
|
|
- }
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
- this.mcpEventCount++;
|
|
|
- this.addToHistory(ev.expression);
|
|
|
- this.addOutput(ev.expression, 'mcp-input', { seq: this.mcpEventCount });
|
|
|
- // 检测 LaTeX 渲染
|
|
|
- if (ev.result && ev.result.startsWith('##TEXFORM##')) {
|
|
|
- const latex = ev.result.substring(11);
|
|
|
- this.addOutput(latex, 'mcp-latex', { renderLatex: true });
|
|
|
- }
|
|
|
- // 检测绘图结果 → Canvas 渲染
|
|
|
- else if (ev.result && ev.result.startsWith('{"type":"plot"')) {
|
|
|
- this.addPlotOutput(ev.result, this.mcpEventCount);
|
|
|
- } else {
|
|
|
- this.addOutput(ev.result, 'mcp-result', { seq: this.mcpEventCount });
|
|
|
- }
|
|
|
- });
|
|
|
- this.scrollToBottom();
|
|
|
- } catch (e) {
|
|
|
- // Ignore polling errors
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 初始化控制台
|
|
|
-let symjaConsole;
|
|
|
-document.addEventListener('DOMContentLoaded', () => {
|
|
|
- symjaConsole = new SymjaConsole();
|
|
|
-});
|
|
|
-
|
|
|
-// ── Canvas 绘图渲染器 ────────────────────────────────────────
|
|
|
-class PlotRenderer {
|
|
|
- constructor() {
|
|
|
- this.canvas = null;
|
|
|
- this.ctx = null;
|
|
|
- this.data = null;
|
|
|
- this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
- this.bounds = { xMin: 0, xMax: 1, yMin: 0, yMax: 1 };
|
|
|
- this.dpr = window.devicePixelRatio || 1;
|
|
|
- this.colors = [
|
|
|
- [0.369, 0.507, 0.710], [0.851, 0.208, 0.125],
|
|
|
- [0.267, 0.612, 0.329], [0.737, 0.349, 0.800],
|
|
|
- [1.0, 0.608, 0.0]
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- render(canvas, data) {
|
|
|
- this.canvas = canvas;
|
|
|
- this.ctx = canvas.getContext('2d');
|
|
|
- this.data = data;
|
|
|
-
|
|
|
- // 计算全局范围
|
|
|
- this.bounds = this.computeBounds(data);
|
|
|
- const padX = (this.bounds.xMax - this.bounds.xMin) * 0.05 || 0.5;
|
|
|
- const padY = (this.bounds.yMax - this.bounds.yMin) * 0.05 || 0.5;
|
|
|
- this.bounds.xMin -= padX;
|
|
|
- this.bounds.xMax += padX;
|
|
|
- this.bounds.yMin -= padY;
|
|
|
- this.bounds.yMax += padY;
|
|
|
-
|
|
|
- // 强制等比例:确保圆不变椭圆
|
|
|
- const m = 60, cw = canvas.width - 2 * m, ch = canvas.height - 2 * m;
|
|
|
- const xr = this.bounds.xMax - this.bounds.xMin;
|
|
|
- const yr = this.bounds.yMax - this.bounds.yMin;
|
|
|
- if (xr > 0 && yr > 0) {
|
|
|
- if (xr / cw > yr / ch) {
|
|
|
- const ny = xr * ch / cw;
|
|
|
- const my = (this.bounds.yMin + this.bounds.yMax) / 2;
|
|
|
- this.bounds.yMin = my - ny / 2;
|
|
|
- this.bounds.yMax = my + ny / 2;
|
|
|
- } else {
|
|
|
- const nx = yr * cw / ch;
|
|
|
- const mx = (this.bounds.xMin + this.bounds.xMax) / 2;
|
|
|
- this.bounds.xMin = mx - nx / 2;
|
|
|
- this.bounds.xMax = mx + nx / 2;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
- this.draw();
|
|
|
-
|
|
|
- // 手势绑定
|
|
|
- this.bindGestures();
|
|
|
- }
|
|
|
-
|
|
|
- computeBounds(data) {
|
|
|
- let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
|
|
|
- if (!data.elements) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
|
|
|
- for (const el of data.elements) {
|
|
|
- if (!el.points) continue;
|
|
|
- for (const pt of el.points) {
|
|
|
- xMin = Math.min(xMin, pt[0]);
|
|
|
- xMax = Math.max(xMax, pt[0]);
|
|
|
- yMin = Math.min(yMin, pt[1]);
|
|
|
- yMax = Math.max(yMax, pt[1]);
|
|
|
- }
|
|
|
- }
|
|
|
- if (!isFinite(xMin)) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
|
|
|
- return { xMin, xMax, yMin, yMax };
|
|
|
- }
|
|
|
-
|
|
|
- // ── 坐标变换 ──────────────────────────────────────
|
|
|
- toCanvas(px, py) {
|
|
|
- const w = this.canvas.width, h = this.canvas.height;
|
|
|
- const m = 60; // 边距
|
|
|
- const cw = w - 2 * m, ch = h - 2 * m;
|
|
|
- const t = this.transform;
|
|
|
- const b = this.bounds;
|
|
|
- const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
|
|
|
- return {
|
|
|
- x: m + (px - b.xMin - t.ox * xRange) / (xRange * t.sx) * cw,
|
|
|
- y: h - m - (py - b.yMin - t.oy * yRange) / (yRange * t.sy) * ch
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- // ── 主绘制 ────────────────────────────────────────
|
|
|
- draw() {
|
|
|
- const ctx = this.ctx;
|
|
|
- const w = this.canvas.width, h = this.canvas.height;
|
|
|
- const m = 60;
|
|
|
- const cw = w - 2 * m, ch = h - 2 * m;
|
|
|
- const t = this.transform;
|
|
|
- const b = this.bounds;
|
|
|
-
|
|
|
- // 背景
|
|
|
- ctx.fillStyle = '#FFFFFF';
|
|
|
- ctx.fillRect(0, 0, w, h);
|
|
|
-
|
|
|
- // 网格
|
|
|
- this.drawGrid(m, cw, ch);
|
|
|
-
|
|
|
- // 坐标轴
|
|
|
- this.drawAxes(m, cw, ch);
|
|
|
-
|
|
|
- // 数据曲线
|
|
|
- if (this.data.elements) {
|
|
|
- const useColors = this.colors;
|
|
|
- this.data.elements.forEach((el, idx) => {
|
|
|
- if (!el.points || el.points.length === 0) return;
|
|
|
- const color = el.color || useColors[idx % useColors.length];
|
|
|
- const rgb = `rgb(${Math.round(color[0]*255)},${Math.round(color[1]*255)},${Math.round(color[2]*255)})`;
|
|
|
-
|
|
|
- if (el.type === 'line' || (el.type === 'point' && this.data.options && this.data.options.joined)) {
|
|
|
- ctx.strokeStyle = rgb;
|
|
|
- ctx.lineWidth = 3 * this.dpr;
|
|
|
- ctx.beginPath();
|
|
|
- for (let i = 0; i < el.points.length; i++) {
|
|
|
- const pt = this.toCanvas(el.points[i][0], el.points[i][1]);
|
|
|
- if (i === 0) ctx.moveTo(pt.x, pt.y);
|
|
|
- else ctx.lineTo(pt.x, pt.y);
|
|
|
- }
|
|
|
- ctx.stroke();
|
|
|
- }
|
|
|
-
|
|
|
- if (el.type === 'point') {
|
|
|
- ctx.fillStyle = rgb;
|
|
|
- for (const pt of el.points) {
|
|
|
- const p = this.toCanvas(pt[0], pt[1]);
|
|
|
- ctx.beginPath();
|
|
|
- ctx.arc(p.x, p.y, 3 * this.dpr, 0, Math.PI * 2);
|
|
|
- ctx.fill();
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- drawGrid(m, cw, ch) {
|
|
|
- const ctx = this.ctx;
|
|
|
- const b = this.bounds;
|
|
|
- const t = this.transform;
|
|
|
- const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
|
|
|
-
|
|
|
- ctx.strokeStyle = '#E8EDF2';
|
|
|
- ctx.lineWidth = Math.round(1 * this.dpr);
|
|
|
- ctx.fillStyle = '#8B95A5';
|
|
|
- ctx.font = '11px -apple-system, sans-serif';
|
|
|
- ctx.textAlign = 'center';
|
|
|
-
|
|
|
- const xStep = this.niceStep(xRange, 5);
|
|
|
- const yStep = this.niceStep(yRange, 5);
|
|
|
-
|
|
|
- // X 网格 + 标签
|
|
|
- let xStart = Math.floor((b.xMin + t.ox * xRange) / xStep) * xStep;
|
|
|
- for (let x = xStart; x <= b.xMin + t.ox * xRange + xRange / t.sx + xStep; x += xStep) {
|
|
|
- const pt = this.toCanvas(x, 0);
|
|
|
- if (pt.x >= m && pt.x <= m + cw) {
|
|
|
- ctx.beginPath();
|
|
|
- ctx.moveTo(pt.x, m);
|
|
|
- ctx.lineTo(pt.x, m + ch);
|
|
|
- ctx.stroke();
|
|
|
- ctx.fillText(this.fmtNum(x), pt.x, m + ch + 16);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Y 网格 + 标签
|
|
|
- ctx.textAlign = 'right';
|
|
|
- let yStart = Math.floor((b.yMin + t.oy * yRange) / yStep) * yStep;
|
|
|
- for (let y = yStart; y <= b.yMin + t.oy * yRange + yRange / t.sy + yStep; y += yStep) {
|
|
|
- const pt = this.toCanvas(0, y);
|
|
|
- if (pt.y >= m && pt.y <= m + ch) {
|
|
|
- ctx.beginPath();
|
|
|
- ctx.moveTo(m, pt.y);
|
|
|
- ctx.lineTo(m + cw, pt.y);
|
|
|
- ctx.stroke();
|
|
|
- ctx.fillText(this.fmtNum(y), m - 8, pt.y + 4);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- drawAxes(m, cw, ch) {
|
|
|
- const ctx = this.ctx;
|
|
|
- const b = this.bounds;
|
|
|
- const t = this.transform;
|
|
|
- const origin = this.toCanvas(0, 0);
|
|
|
-
|
|
|
- ctx.strokeStyle = '#1976D2';
|
|
|
- ctx.lineWidth = Math.round(2 * this.dpr);
|
|
|
- ctx.fillStyle = '#1976D2';
|
|
|
- ctx.font = 'bold 11px -apple-system, sans-serif';
|
|
|
-
|
|
|
- // X 轴
|
|
|
- if (origin.y >= m && origin.y <= m + ch) {
|
|
|
- ctx.beginPath();
|
|
|
- ctx.moveTo(m, origin.y);
|
|
|
- ctx.lineTo(m + cw, origin.y);
|
|
|
- ctx.stroke();
|
|
|
- // 箭头
|
|
|
- ctx.beginPath();
|
|
|
- ctx.moveTo(m + cw, origin.y);
|
|
|
- ctx.lineTo(m + cw - 8, origin.y - 4);
|
|
|
- ctx.lineTo(m + cw - 8, origin.y + 4);
|
|
|
- ctx.fill();
|
|
|
- }
|
|
|
-
|
|
|
- // Y 轴
|
|
|
- if (origin.x >= m && origin.x <= m + cw) {
|
|
|
- ctx.beginPath();
|
|
|
- ctx.moveTo(origin.x, m + ch);
|
|
|
- ctx.lineTo(origin.x, m);
|
|
|
- ctx.stroke();
|
|
|
- // 箭头
|
|
|
- ctx.beginPath();
|
|
|
- ctx.moveTo(origin.x, m);
|
|
|
- ctx.lineTo(origin.x - 4, m + 8);
|
|
|
- ctx.lineTo(origin.x + 4, m + 8);
|
|
|
- ctx.fill();
|
|
|
- }
|
|
|
-
|
|
|
- // 原点标签
|
|
|
- if (origin.x >= m && origin.x <= m + cw && origin.y >= m && origin.y <= m + ch) {
|
|
|
- ctx.textAlign = 'right';
|
|
|
- ctx.fillText('O', origin.x - 6, origin.y + 14);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- niceStep(range, target) {
|
|
|
- const rough = range / target;
|
|
|
- const exp = Math.pow(10, Math.floor(Math.log10(rough)));
|
|
|
- const mant = rough / exp;
|
|
|
- let nice = 1;
|
|
|
- if (mant <= 1.5) nice = 1;
|
|
|
- else if (mant <= 3.5) nice = 2;
|
|
|
- else if (mant <= 7.5) nice = 5;
|
|
|
- else nice = 10;
|
|
|
- return nice * exp;
|
|
|
- }
|
|
|
-
|
|
|
- fmtNum(n) {
|
|
|
- if (Math.abs(n) < 1e-10) return '0';
|
|
|
- if (Math.abs(n) < 0.001 || Math.abs(n) >= 1e6) return n.toExponential(2);
|
|
|
- if (Number.isInteger(n)) return n.toString();
|
|
|
- const s = n.toFixed(4);
|
|
|
- return s.replace(/0+$/, '').replace(/\.$/, '');
|
|
|
- }
|
|
|
-
|
|
|
- // ── 手势 ──────────────────────────────────────────
|
|
|
- bindGestures() {
|
|
|
- const canvas = this.canvas;
|
|
|
- // 移除旧监听器防止泄露
|
|
|
- if (this._touchHandlers) {
|
|
|
- canvas.removeEventListener('touchstart', this._touchHandlers.start);
|
|
|
- canvas.removeEventListener('touchmove', this._touchHandlers.move);
|
|
|
- canvas.removeEventListener('touchend', this._touchHandlers.end);
|
|
|
- }
|
|
|
- let lastDist = 0, lastX = 0, lastY = 0, lastTap = 0, initialPinch = false;
|
|
|
- const self = this;
|
|
|
-
|
|
|
- const onStart = (e) => {
|
|
|
- if (e.touches.length >= 2) {
|
|
|
- const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
|
- const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
|
- lastDist = Math.sqrt(dx*dx + dy*dy);
|
|
|
- initialPinch = true;
|
|
|
- }
|
|
|
- if (e.touches.length === 1 && !initialPinch) {
|
|
|
- lastX = e.touches[0].clientX;
|
|
|
- lastY = e.touches[0].clientY;
|
|
|
- }
|
|
|
- };
|
|
|
- const onMove = (e) => {
|
|
|
- if (e.touches.length >= 2 && initialPinch && lastDist > 0) {
|
|
|
- const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
|
- const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
|
- const dist = Math.sqrt(dx*dx + dy*dy);
|
|
|
- const scale = dist / lastDist;
|
|
|
- if (scale > 0.3 && scale < 3) {
|
|
|
- self.transform.sx = Math.max(0.1, Math.min(20, self.transform.sx / scale));
|
|
|
- self.transform.sy = Math.max(0.1, Math.min(20, self.transform.sy / scale));
|
|
|
- }
|
|
|
- lastDist = dist;
|
|
|
- self.draw();
|
|
|
- e.preventDefault();
|
|
|
- } else if (e.touches.length === 1 && !initialPinch) {
|
|
|
- const dx = (e.touches[0].clientX - lastX) / canvas.clientWidth;
|
|
|
- const dy = (e.touches[0].clientY - lastY) / canvas.clientHeight;
|
|
|
- self.transform.ox -= dx / self.transform.sx;
|
|
|
- self.transform.oy += dy / self.transform.sy;
|
|
|
- lastX = e.touches[0].clientX;
|
|
|
- lastY = e.touches[0].clientY;
|
|
|
- self.draw();
|
|
|
- e.preventDefault();
|
|
|
- }
|
|
|
- };
|
|
|
- const onEnd = (e) => {
|
|
|
- if (e.touches.length === 0) {
|
|
|
- const now = Date.now();
|
|
|
- if (initialPinch) {
|
|
|
- initialPinch = false;
|
|
|
- } else if (now - lastTap < 350) {
|
|
|
- self.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
- self.draw();
|
|
|
- }
|
|
|
- lastTap = now;
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- canvas.addEventListener('touchstart', onStart, { passive: true });
|
|
|
- canvas.addEventListener('touchmove', onMove, { passive: false });
|
|
|
- canvas.addEventListener('touchend', onEnd, { passive: true });
|
|
|
- this._touchHandlers = { start: onStart, move: onMove, end: onEnd };
|
|
|
- }
|
|
|
-}
|
|
|
+// 控制台交互逻辑
|
|
|
+class SymjaConsole {
|
|
|
+ constructor() {
|
|
|
+ this.output = document.getElementById('output');
|
|
|
+ this.input = document.getElementById('input');
|
|
|
+ this.inputPrompt = document.getElementById('inputPrompt');
|
|
|
+ this.completionList = document.getElementById('completionList');
|
|
|
+ this.helpBtn = document.getElementById('helpBtn');
|
|
|
+ this.pluginsBtn = document.getElementById('pluginsBtn');
|
|
|
+ this.settingsBtn = document.getElementById('settingsBtn');
|
|
|
+
|
|
|
+ // 历史记录
|
|
|
+ this.history = this.loadHistory();
|
|
|
+ this.historyIndex = this.history.length;
|
|
|
+ this.tempInput = '';
|
|
|
+
|
|
|
+ // 插件相关
|
|
|
+ this.plugins = [];
|
|
|
+ this.currentPlugin = null;
|
|
|
+
|
|
|
+ // 自动补全相关
|
|
|
+ this.completions = [];
|
|
|
+ this.completionIndex = -1;
|
|
|
+
|
|
|
+ // 变量追踪
|
|
|
+ this.variables = new Set();
|
|
|
+
|
|
|
+ // 执行计数器
|
|
|
+ this.executionCount = this.loadExecutionCount();
|
|
|
+
|
|
|
+ this.init();
|
|
|
+ this.loadPlugins();
|
|
|
+ this.refreshPluginList(); // 初始化时刷新插件列表
|
|
|
+ this.showWelcome();
|
|
|
+ this.updatePrompt();
|
|
|
+ }
|
|
|
+
|
|
|
+ init() {
|
|
|
+ // 输入框事件
|
|
|
+ this.input.addEventListener('keydown', (e) => this.handleKeyDown(e));
|
|
|
+ this.input.addEventListener('input', () => this.handleInput());
|
|
|
+
|
|
|
+ // 发送按钮
|
|
|
+ const sendBtn = document.getElementById('sendBtn');
|
|
|
+ if (sendBtn) {
|
|
|
+ sendBtn.addEventListener('click', () => this.evaluate());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 顶部导航按钮
|
|
|
+ this.helpBtn?.addEventListener('click', () => this.showHelp());
|
|
|
+ this.pluginsBtn?.addEventListener('click', () => this.showPluginPanel());
|
|
|
+ this.settingsBtn?.addEventListener('click', () => this.showSettings());
|
|
|
+
|
|
|
+ // 设置面板
|
|
|
+ document.getElementById('closeSettings')?.addEventListener('click', () => this.hideSettings());
|
|
|
+ document.getElementById('applySettings')?.addEventListener('click', () => this.applySettings());
|
|
|
+
|
|
|
+ // 加载已保存的设置
|
|
|
+ this.loadSettings();
|
|
|
+
|
|
|
+ // MCP 事件轮询(仅在服务器开启时运行)
|
|
|
+ this.mcpEventCount = 0;
|
|
|
+ this.mcpPollTimer = null;
|
|
|
+ this.startMcpPollingIfNeeded();
|
|
|
+
|
|
|
+ // 快捷键工具栏
|
|
|
+ this.initShortcutsBar();
|
|
|
+
|
|
|
+ // 帮助页面关闭按钮
|
|
|
+ document.getElementById('closeHelp')?.addEventListener('click', () => {
|
|
|
+ this.hideHelp();
|
|
|
+ });
|
|
|
+
|
|
|
+ // 恢复默认插件
|
|
|
+ document.getElementById('restorePluginsBtn')?.addEventListener('click', () => {
|
|
|
+ try {
|
|
|
+ const result = Android.restoreDefaultPlugins();
|
|
|
+ this.loadPlugins();
|
|
|
+ this.refreshPluginList();
|
|
|
+ this.addOutput(result, 'help');
|
|
|
+ this.scrollToBottom();
|
|
|
+ } catch(e) {
|
|
|
+ this.addOutput('❌ 恢复失败: ' + e.message, 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 全屏面板关闭
|
|
|
+ document.getElementById('closeFullscreen')?.addEventListener('click', () => {
|
|
|
+ const fs = document.getElementById('fullscreenPanel');
|
|
|
+ fs.classList.remove('show');
|
|
|
+ // 调用插件的 IJS.ui.close() 进行清理(停止音乐、动画等)
|
|
|
+ if (this._lastIjsApi && this._lastIjsApi.ui) {
|
|
|
+ this._lastIjsApi.ui.close();
|
|
|
+ }
|
|
|
+ // 确保动画停止
|
|
|
+ if (this._lastIjsApi && this._lastIjsApi.canvas) {
|
|
|
+ this._lastIjsApi.canvas.stop();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 插件面板事件
|
|
|
+ document.getElementById('closePluginPanel')?.addEventListener('click', () => {
|
|
|
+ this.hidePluginPanel();
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('closePluginDialog')?.addEventListener('click', () => {
|
|
|
+ this.hidePluginDialog();
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('cancelPlugin')?.addEventListener('click', () => {
|
|
|
+ this.hidePluginDialog();
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('executePlugin')?.addEventListener('click', () => {
|
|
|
+ this.executeCurrentPlugin();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ initShortcutsBar() {
|
|
|
+ const buttons = document.querySelectorAll('.shortcut-btn');
|
|
|
+ buttons.forEach(btn => {
|
|
|
+ btn.addEventListener('click', (e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ const action = btn.dataset.action;
|
|
|
+ const value = btn.dataset.value;
|
|
|
+
|
|
|
+ switch(action) {
|
|
|
+ case 'bracket':
|
|
|
+ case 'insert':
|
|
|
+ this.insertText(value);
|
|
|
+ break;
|
|
|
+ case 'history-up':
|
|
|
+ this.navigateHistory(-1);
|
|
|
+ break;
|
|
|
+ case 'history-down':
|
|
|
+ this.navigateHistory(1);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保持输入框焦点
|
|
|
+ this.input.focus();
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 初始化插件标签页
|
|
|
+ this.initPluginTabs();
|
|
|
+
|
|
|
+ // 初始化插件分享功能
|
|
|
+ this.initPluginShare();
|
|
|
+ }
|
|
|
+
|
|
|
+ initPluginTabs() {
|
|
|
+ const tabs = document.querySelectorAll('.plugin-tab');
|
|
|
+ const contents = document.querySelectorAll('.tab-content');
|
|
|
+
|
|
|
+ tabs.forEach(tab => {
|
|
|
+ tab.addEventListener('click', () => {
|
|
|
+ const targetTab = tab.dataset.tab;
|
|
|
+
|
|
|
+ // 切换标签激活状态
|
|
|
+ tabs.forEach(t => t.classList.remove('active'));
|
|
|
+ tab.classList.add('active');
|
|
|
+
|
|
|
+ // 切换内容显示
|
|
|
+ contents.forEach(content => {
|
|
|
+ if (content.dataset.content === targetTab) {
|
|
|
+ content.classList.add('active');
|
|
|
+ } else {
|
|
|
+ content.classList.remove('active');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 如果切换到导入/分享标签,更新插件列表
|
|
|
+ if (targetTab === 'import') {
|
|
|
+ this.updatePluginSelects();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ initPluginShare() {
|
|
|
+ // 使用事件委托,在document上监听点击事件
|
|
|
+ document.addEventListener('click', (e) => {
|
|
|
+ const target = e.target;
|
|
|
+
|
|
|
+ // 生成分享码
|
|
|
+ if (target.id === 'generateShareCode') {
|
|
|
+ console.log('generateShareCode clicked');
|
|
|
+ e.preventDefault();
|
|
|
+ this.generateShareCode();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 复制分享码
|
|
|
+ if (target.id === 'copyShareCode') {
|
|
|
+ console.log('copyShareCode clicked');
|
|
|
+ e.preventDefault();
|
|
|
+ this.copyShareCode();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 导入插件
|
|
|
+ if (target.id === 'importPlugin') {
|
|
|
+ console.log('importPlugin clicked');
|
|
|
+ e.preventDefault();
|
|
|
+ this.importPlugin();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 删除插件
|
|
|
+ if (target.id === 'deletePlugin') {
|
|
|
+ console.log('deletePlugin clicked');
|
|
|
+ e.preventDefault();
|
|
|
+ this.deletePluginConfirm();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ updatePluginSelects() {
|
|
|
+ const shareSelect = document.getElementById('sharePluginSelect');
|
|
|
+ const deleteSelect = document.getElementById('deletePluginSelect');
|
|
|
+
|
|
|
+ if (shareSelect) {
|
|
|
+ shareSelect.innerHTML = '<option value="">选择插件...</option>';
|
|
|
+ this.plugins.forEach((plugin, index) => {
|
|
|
+ const option = document.createElement('option');
|
|
|
+ option.value = index;
|
|
|
+ option.textContent = `${plugin.icon} ${plugin.name}`;
|
|
|
+ shareSelect.appendChild(option);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (deleteSelect) {
|
|
|
+ deleteSelect.innerHTML = '<option value="">选择要删除的插件...</option>';
|
|
|
+ this.plugins.forEach((plugin, index) => {
|
|
|
+ const option = document.createElement('option');
|
|
|
+ option.value = index;
|
|
|
+ option.textContent = `${plugin.icon} ${plugin.name}`;
|
|
|
+ deleteSelect.appendChild(option);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ generateShareCode() {
|
|
|
+ const select = document.getElementById('sharePluginSelect');
|
|
|
+ const output = document.getElementById('shareCodeOutput');
|
|
|
+ const copyBtn = document.getElementById('copyShareCode');
|
|
|
+
|
|
|
+ if (!select || !output) return;
|
|
|
+
|
|
|
+ const index = parseInt(select.value);
|
|
|
+ if (isNaN(index) || index < 0 || index >= this.plugins.length) {
|
|
|
+ this.addOutput('❌ 请先选择一个插件', 'error');
|
|
|
+ this.scrollToBottom();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const plugin = this.plugins[index];
|
|
|
+ const shareCode = JSON.stringify(plugin, null, 2);
|
|
|
+
|
|
|
+ output.value = shareCode;
|
|
|
+ if (copyBtn) copyBtn.style.display = 'block';
|
|
|
+
|
|
|
+ this.addOutput(`✅ 已生成 "${plugin.name}" 的分享码`, 'help');
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+
|
|
|
+ copyShareCode() {
|
|
|
+ const output = document.getElementById('shareCodeOutput');
|
|
|
+ if (!output || !output.value) return;
|
|
|
+
|
|
|
+ output.select();
|
|
|
+ document.execCommand('copy');
|
|
|
+
|
|
|
+ this.addOutput('📋 分享码已复制到剪贴板', 'help');
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+
|
|
|
+ importPlugin() {
|
|
|
+ const input = document.getElementById('importCodeInput');
|
|
|
+ if (!input || !input.value.trim()) {
|
|
|
+ this.addOutput('❌ 请粘贴插件代码', 'error');
|
|
|
+ this.scrollToBottom();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const pluginData = JSON.parse(input.value);
|
|
|
+
|
|
|
+ // 验证插件格式
|
|
|
+ if (!pluginData.id || !pluginData.name || !pluginData.jsCode) {
|
|
|
+ throw new Error('插件格式不正确,缺少必要字段');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用 Android 接口保存插件
|
|
|
+ try {
|
|
|
+ const result = Android.savePlugin(JSON.stringify(pluginData));
|
|
|
+
|
|
|
+ // 重新加载插件
|
|
|
+ this.loadPlugins();
|
|
|
+ this.updatePluginSelects();
|
|
|
+
|
|
|
+ // 刷新插件列表显示
|
|
|
+ this.refreshPluginList();
|
|
|
+
|
|
|
+ // 显示成功消息
|
|
|
+ this.addOutput(`✅ ${result}`, 'help');
|
|
|
+ this.addOutput(`📦 插件 "${pluginData.name}" 已添加到列表`, 'help');
|
|
|
+
|
|
|
+ // 清空输入
|
|
|
+ input.value = '';
|
|
|
+ } catch (e) {
|
|
|
+ this.addOutput(`❌ 保存失败: ${e.message}`, 'error');
|
|
|
+ }
|
|
|
+
|
|
|
+ this.scrollToBottom();
|
|
|
+ } catch (e) {
|
|
|
+ this.addOutput(`❌ 解析失败: ${e.message}`, 'error');
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ deletePluginConfirm() {
|
|
|
+ const select = document.getElementById('deletePluginSelect');
|
|
|
+ if (!select) {
|
|
|
+ console.error('deletePluginSelect element not found');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('Select value:', select.value);
|
|
|
+ console.log('Plugins length:', this.plugins.length);
|
|
|
+
|
|
|
+ const index = parseInt(select.value);
|
|
|
+ if (isNaN(index) || index < 0 || index >= this.plugins.length) {
|
|
|
+ this.addOutput('❌ 请先选择要删除的插件', 'error');
|
|
|
+ this.scrollToBottom();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const plugin = this.plugins[index];
|
|
|
+ console.log('Deleting plugin:', plugin.name, 'ID:', plugin.id);
|
|
|
+
|
|
|
+ // 使用自定义确认对话框或直接删除
|
|
|
+ const confirmMessage = `确定要删除插件 "${plugin.name}" 吗?`;
|
|
|
+ console.log('Showing confirm dialog:', confirmMessage);
|
|
|
+
|
|
|
+ let userConfirmed = false;
|
|
|
+ try {
|
|
|
+ userConfirmed = confirm(confirmMessage);
|
|
|
+ console.log('User confirmed:', userConfirmed);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Confirm dialog error:', e);
|
|
|
+ // 如果confirm失败,直接删除
|
|
|
+ userConfirmed = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (userConfirmed) {
|
|
|
+ console.log('Proceeding with deletion...');
|
|
|
+ try {
|
|
|
+ const result = Android.deletePlugin(plugin.id);
|
|
|
+ console.log('Delete result:', result);
|
|
|
+ this.addOutput(`✅ ${result}`, 'help');
|
|
|
+
|
|
|
+ // 重新加载插件
|
|
|
+ this.loadPlugins();
|
|
|
+ this.updatePluginSelects();
|
|
|
+
|
|
|
+ // 刷新插件列表显示
|
|
|
+ this.refreshPluginList();
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Delete error:', e);
|
|
|
+ this.addOutput(`❌ 删除失败: ${e.message}`, 'error');
|
|
|
+ }
|
|
|
+
|
|
|
+ this.scrollToBottom();
|
|
|
+ } else {
|
|
|
+ console.log('User cancelled deletion');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ refreshPluginList() {
|
|
|
+ const list = document.getElementById('pluginList');
|
|
|
+ console.log('refreshPluginList called, list element:', list);
|
|
|
+ console.log('Current plugins count:', this.plugins.length);
|
|
|
+
|
|
|
+ if (!list) return;
|
|
|
+
|
|
|
+ list.innerHTML = '';
|
|
|
+
|
|
|
+ if (this.plugins.length === 0) {
|
|
|
+ console.log('No plugins, showing empty message');
|
|
|
+ list.innerHTML = '<div class="no-plugins">暂无插件</div>';
|
|
|
+ } else {
|
|
|
+ console.log('Rendering', this.plugins.length, 'plugins');
|
|
|
+ this.plugins.forEach(plugin => {
|
|
|
+ const item = document.createElement('div');
|
|
|
+ item.className = 'plugin-item';
|
|
|
+ const commandInfo = plugin.command ? `<div class="plugin-command">命令: ${plugin.command}</div>` : '';
|
|
|
+ item.innerHTML = `
|
|
|
+ <div class="plugin-icon">${plugin.icon}</div>
|
|
|
+ <div class="plugin-info">
|
|
|
+ <div class="plugin-name">${plugin.name}</div>
|
|
|
+ <div class="plugin-desc">${plugin.description}</div>
|
|
|
+ ${commandInfo}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ item.addEventListener('click', () => this.openPlugin(plugin));
|
|
|
+ list.appendChild(item);
|
|
|
+ });
|
|
|
+ console.log('Finished rendering plugins, list children:', list.children.length);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ insertText(text) {
|
|
|
+ const start = this.input.selectionStart;
|
|
|
+ const end = this.input.selectionEnd;
|
|
|
+ const value = this.input.value;
|
|
|
+
|
|
|
+ // 插入文本
|
|
|
+ this.input.value = value.substring(0, start) + text + value.substring(end);
|
|
|
+
|
|
|
+ // 设置光标位置
|
|
|
+ const newPos = start + text.length;
|
|
|
+ this.input.setSelectionRange(newPos, newPos);
|
|
|
+
|
|
|
+ // 触发输入事件
|
|
|
+ this.handleInput();
|
|
|
+ }
|
|
|
+
|
|
|
+ handleKeyDown(e) {
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ this.hideCompletionList();
|
|
|
+ this.evaluate();
|
|
|
+ } else if (e.key === 'ArrowUp') {
|
|
|
+ e.preventDefault();
|
|
|
+ this.hideCompletionList();
|
|
|
+ this.navigateHistory(-1);
|
|
|
+ } else if (e.key === 'ArrowDown') {
|
|
|
+ e.preventDefault();
|
|
|
+ this.hideCompletionList();
|
|
|
+ this.navigateHistory(1);
|
|
|
+ } else if (e.key === 'Escape') {
|
|
|
+ this.input.value = '';
|
|
|
+ this.completions = [];
|
|
|
+ this.completionIndex = -1;
|
|
|
+ this.hideCompletionList();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ handleInput() {
|
|
|
+ // 更新自动补全列表
|
|
|
+ console.log('handleInput called, value:', this.input.value);
|
|
|
+ this.updateCompletionList();
|
|
|
+ }
|
|
|
+
|
|
|
+ showWelcome() {
|
|
|
+ const line = document.createElement('div');
|
|
|
+ line.className = 'terminal-line';
|
|
|
+ line.innerHTML = `<div class="welcome-message">
|
|
|
+ <h2>🧮 某人的计算器 v1.3</h2>
|
|
|
+ <p>基于 Symja 的交互式符号计算控制台</p>
|
|
|
+ <p><strong>快速开始:</strong></p>
|
|
|
+ <p><code>2+2</code> 算术运算</p>
|
|
|
+ <p><code>D[x^3, x]</code> 求导</p>
|
|
|
+ <p><code>Integrate[x^2, {x,0,1}]</code> 定积分</p>
|
|
|
+ <p><code>Solve[x^2-5*x+6==0, x]</code> 解方程</p>
|
|
|
+ <p><code>Plot[Sin[x], {x,0,2*Pi}]</code> 函数绘图</p>
|
|
|
+ <p style="margin-top:12px">输入 <code>help</code> 查看完整文档 · 输入 <code>plugins</code> 管理插件</p>
|
|
|
+ </div>`;
|
|
|
+ this.output.appendChild(line);
|
|
|
+ }
|
|
|
+
|
|
|
+ evaluate() {
|
|
|
+ let expression = this.input.value.trim();
|
|
|
+
|
|
|
+ if (!expression) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理特殊命令
|
|
|
+ if (this.handleSpecialCommand(expression)) {
|
|
|
+ this.addToHistory(expression);
|
|
|
+ this.input.value = '';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加到历史记录
|
|
|
+ this.addToHistory(expression);
|
|
|
+
|
|
|
+ // 显示输入(带计数器)
|
|
|
+ this.addOutput(expression, 'input', { count: this.executionCount });
|
|
|
+
|
|
|
+ // 调用Android接口进行计算
|
|
|
+ try {
|
|
|
+ const result = Android.evaluatePlot(expression);
|
|
|
+
|
|
|
+ // 检测是否是 Graphics/绘图结果
|
|
|
+ if (typeof result === 'string' && result.startsWith('{"type":"plot"')) {
|
|
|
+ this.addPlotOutput(result, this.executionCount);
|
|
|
+ }
|
|
|
+ // LaTeX 渲染
|
|
|
+ else if (result.startsWith('##TEXFORM##')) {
|
|
|
+ const latex = result.substring(11);
|
|
|
+ this.addOutput(latex, 'result', { renderLatex: true, count: this.executionCount });
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+ // 普通文本结果
|
|
|
+ else {
|
|
|
+ this.addOutput(result, 'result', { count: this.executionCount });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 追踪变量
|
|
|
+ this.trackVariables(expression);
|
|
|
+ } catch (e) {
|
|
|
+ this.addOutput('错误: 无法连接到计算引擎', 'error');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 增加计数器
|
|
|
+ this.executionCount++;
|
|
|
+ this.saveExecutionCount();
|
|
|
+ this.updatePrompt();
|
|
|
+
|
|
|
+ // 清空输入框
|
|
|
+ this.input.value = '';
|
|
|
+ this.completions = [];
|
|
|
+ this.completionIndex = -1;
|
|
|
+
|
|
|
+ // 滚动到底部
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+
|
|
|
+ addPlotOutput(plotJson, count, labels) {
|
|
|
+ const data = JSON.parse(plotJson);
|
|
|
+ const line = document.createElement('div');
|
|
|
+ line.className = 'terminal-line';
|
|
|
+
|
|
|
+ const container = document.createElement('div');
|
|
|
+ container.className = 'plot-container';
|
|
|
+
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ canvas.className = 'plot-canvas';
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
+ canvas.width = 500 * dpr;
|
|
|
+ canvas.height = 500 * dpr;
|
|
|
+ canvas.style.width = '100%';
|
|
|
+ canvas.style.height = '100%';
|
|
|
+ container.appendChild(canvas);
|
|
|
+
|
|
|
+ // 长按保存(单指静止800ms触发)
|
|
|
+ let longPressTimer;
|
|
|
+ canvas.addEventListener('touchstart', (e) => {
|
|
|
+ clearTimeout(longPressTimer);
|
|
|
+ if (e.touches.length === 1) {
|
|
|
+ longPressTimer = setTimeout(() => this.savePlot(canvas), 800);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ canvas.addEventListener('touchend', () => clearTimeout(longPressTimer));
|
|
|
+ canvas.addEventListener('touchmove', () => clearTimeout(longPressTimer));
|
|
|
+
|
|
|
+ line.appendChild(container);
|
|
|
+ this.output.appendChild(line);
|
|
|
+
|
|
|
+ if (!this.plotRenderer) {
|
|
|
+ this.plotRenderer = new PlotRenderer();
|
|
|
+ }
|
|
|
+ this.plotRenderer.render(canvas, data);
|
|
|
+ }
|
|
|
+
|
|
|
+ buildLegend(data, labels) {
|
|
|
+ const div = document.createElement('div');
|
|
|
+ div.className = 'plot-legend';
|
|
|
+ const colors = [
|
|
|
+ [0.369,0.507,0.710],[0.851,0.208,0.125],[0.267,0.612,0.329],
|
|
|
+ [0.737,0.349,0.800],[1.0,0.608,0.0]
|
|
|
+ ];
|
|
|
+ data.elements.forEach((el, i) => {
|
|
|
+ const c = el.color || colors[i % colors.length];
|
|
|
+ const rgb = `rgb(${Math.round(c[0]*255)},${Math.round(c[1]*255)},${Math.round(c[2]*255)})`;
|
|
|
+ const item = document.createElement('span');
|
|
|
+ item.className = 'legend-item';
|
|
|
+ item.innerHTML = `<span class="legend-swatch" style="background:${rgb}"></span>${labels ? labels[i] : '曲线'+(i+1)}`;
|
|
|
+ div.appendChild(item);
|
|
|
+ });
|
|
|
+ return div;
|
|
|
+ }
|
|
|
+
|
|
|
+ savePlot(canvas) {
|
|
|
+ try {
|
|
|
+ const dataUrl = canvas.toDataURL('image/png');
|
|
|
+ const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
|
|
+ const filename = 'IJS_Plot_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
|
|
|
+ const result = Android.savePlotImage(base64, filename);
|
|
|
+ this.addOutput(result, 'help');
|
|
|
+ this.scrollToBottom();
|
|
|
+ } catch (e) {
|
|
|
+ this.addOutput('保存失败: ' + e.message, 'error');
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ handleSpecialCommand(cmd) {
|
|
|
+ const lower = cmd.toLowerCase();
|
|
|
+
|
|
|
+ if (lower === 'help' || lower === '?') {
|
|
|
+ this.showHelp();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (lower === 'clear' || lower === 'cls') {
|
|
|
+ this.clear();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (lower === 'history') {
|
|
|
+ this.showHistory();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (lower === 'plugins') {
|
|
|
+ this.showPluginPanel();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (lower === 'mcp') {
|
|
|
+ this.showMcpAddress();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // 调试:通过MCP evaluate("__open plugin_id")打开全屏插件
|
|
|
+ if (lower.startsWith('__open ')) {
|
|
|
+ var pid = lower.substring(7).trim();
|
|
|
+ var pl = this.plugins.find(function(p) { return p.id === pid || p.command === pid; });
|
|
|
+ if (pl) this.openPlugin(pl);
|
|
|
+ else this.addOutput('插件未找到: ' + pid, 'error');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (lower === 'feedback') {
|
|
|
+ this.showFeedback();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (lower.startsWith('clear ')) {
|
|
|
+ const varName = cmd.substring(6).trim();
|
|
|
+ this.clearVariable(varName);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否是插件命令
|
|
|
+ if (this.handlePluginCommand(lower)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ handlePluginCommand(cmd) {
|
|
|
+ // 检查是否是带参数的插件命令格式: command[arg1, arg2, ...]
|
|
|
+ const paramMatch = cmd.match(/^([a-z0-9_-]+)\[(.*)\]$/i);
|
|
|
+
|
|
|
+ if (paramMatch) {
|
|
|
+ const commandName = paramMatch[1].toLowerCase();
|
|
|
+ const argsString = paramMatch[2];
|
|
|
+
|
|
|
+ // 查找匹配的插件
|
|
|
+ const plugin = this.plugins.find(p => p.command && p.command.toLowerCase() === commandName);
|
|
|
+
|
|
|
+ if (plugin) {
|
|
|
+ // 解析参数
|
|
|
+ const args = this.parsePluginArgs(argsString);
|
|
|
+
|
|
|
+ // 验证参数数量
|
|
|
+ if (args.length !== plugin.inputs.length) {
|
|
|
+ this.addOutput(`❌ 参数数量不匹配。${plugin.name} 需要 ${plugin.inputs.length} 个参数,但提供了 ${args.length} 个`, 'error');
|
|
|
+ this.scrollToBottom();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 执行插件(直接模式)
|
|
|
+ this.executePluginDirect(plugin, args);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 查找匹配的插件命令(无参数,打开对话框)
|
|
|
+ for (const plugin of this.plugins) {
|
|
|
+ if (plugin.command && plugin.command.toLowerCase() === cmd) {
|
|
|
+ this.addOutput(`🔌 打开插件: ${plugin.name}`, 'help');
|
|
|
+ this.scrollToBottom();
|
|
|
+ this.openPlugin(plugin);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ parsePluginArgs(argsString) {
|
|
|
+ // 解析逗号分隔的参数,支持引号、大括号、方括号内的逗号
|
|
|
+ const args = [];
|
|
|
+ let current = '';
|
|
|
+ let inQuotes = false;
|
|
|
+ let quoteChar = '';
|
|
|
+ let bracketDepth = 0; // 跟踪 {} 的嵌套深度
|
|
|
+ let squareBracketDepth = 0; // 跟踪 [] 的嵌套深度
|
|
|
+
|
|
|
+ for (let i = 0; i < argsString.length; i++) {
|
|
|
+ const char = argsString[i];
|
|
|
+
|
|
|
+ // 处理引号
|
|
|
+ if ((char === '"' || char === "'") && (i === 0 || argsString[i-1] !== '\\')) {
|
|
|
+ if (!inQuotes) {
|
|
|
+ inQuotes = true;
|
|
|
+ quoteChar = char;
|
|
|
+ } else if (char === quoteChar) {
|
|
|
+ inQuotes = false;
|
|
|
+ quoteChar = '';
|
|
|
+ }
|
|
|
+ current += char;
|
|
|
+ }
|
|
|
+ // 处理大括号
|
|
|
+ else if (char === '{' && !inQuotes) {
|
|
|
+ bracketDepth++;
|
|
|
+ current += char;
|
|
|
+ }
|
|
|
+ else if (char === '}' && !inQuotes) {
|
|
|
+ bracketDepth--;
|
|
|
+ current += char;
|
|
|
+ }
|
|
|
+ // 处理方括号
|
|
|
+ else if (char === '[' && !inQuotes) {
|
|
|
+ squareBracketDepth++;
|
|
|
+ current += char;
|
|
|
+ }
|
|
|
+ else if (char === ']' && !inQuotes) {
|
|
|
+ squareBracketDepth--;
|
|
|
+ current += char;
|
|
|
+ }
|
|
|
+ // 处理逗号分隔符
|
|
|
+ else if (char === ',' && !inQuotes && bracketDepth === 0 && squareBracketDepth === 0) {
|
|
|
+ args.push(current.trim().replace(/^["']|["']$/g, ''));
|
|
|
+ current = '';
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ current += char;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (current.trim()) {
|
|
|
+ args.push(current.trim().replace(/^["']|["']$/g, ''));
|
|
|
+ }
|
|
|
+
|
|
|
+ return args;
|
|
|
+ }
|
|
|
+
|
|
|
+ executePluginDirect(plugin, args) {
|
|
|
+ try {
|
|
|
+ console.log('executePluginDirect called with args:', args);
|
|
|
+
|
|
|
+ // 构建输入对象,并转换为 Symja 可运算类型
|
|
|
+ const inputValues = {};
|
|
|
+ plugin.inputs.forEach((input, index) => {
|
|
|
+ const originalArg = args[index];
|
|
|
+ const convertedArg = this.convertInputToSymjaType(originalArg);
|
|
|
+ console.log(`Converting arg[${index}]: "${originalArg}" -> "${convertedArg}"`);
|
|
|
+ inputValues[input.name] = convertedArg;
|
|
|
+ });
|
|
|
+
|
|
|
+ console.log('Final inputValues:', inputValues);
|
|
|
+
|
|
|
+ // 用于存储插件的控制台输出
|
|
|
+ const pluginOutputs = [];
|
|
|
+
|
|
|
+ const ijsAPI = this.createIJSAPI(pluginOutputs);
|
|
|
+ const func = new Function('inputs', 'IJS', plugin.jsCode + '\nreturn calculate(inputs, IJS);');
|
|
|
+ const result = func(inputValues, ijsAPI);
|
|
|
+
|
|
|
+ console.log('Plugin result:', result);
|
|
|
+
|
|
|
+ // 验证返回值
|
|
|
+ if (typeof result !== 'string' || result.trim() === '') {
|
|
|
+ throw new Error('插件必须返回非空字符串类型的 Symja 表达式');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 验证返回值是否是有效的 Symja 表达式
|
|
|
+ try {
|
|
|
+ Android.symjaEval(result);
|
|
|
+ } catch (e) {
|
|
|
+ throw new Error('插件返回值必须是有效的 Symja 表达式,当前返回: ' + result);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 显示插件名称和参数
|
|
|
+ const argsDisplay = args.map((arg, i) => `${plugin.inputs[i].name}=${arg}`).join(', ');
|
|
|
+ this.addOutput(`${plugin.icon} ${plugin.name}[${argsDisplay}]`, 'input', { count: this.executionCount });
|
|
|
+
|
|
|
+ // 显示插件的控制台输出(如果有)
|
|
|
+ if (pluginOutputs.length > 0) {
|
|
|
+ pluginOutputs.forEach(output => {
|
|
|
+ this.addOutput(output, 'plugin-info');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 显示插件的返回值(可运算结果)
|
|
|
+ this.addOutput(result, 'result', { count: this.executionCount });
|
|
|
+
|
|
|
+ // 将插件结果保存到 Symja 引擎,使其可以通过 % 引用
|
|
|
+ try {
|
|
|
+ Android.evaluate(`Out = ${result}`);
|
|
|
+ } catch (e) {
|
|
|
+ console.log('Plugin result storage error:', e);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 增加计数器
|
|
|
+ this.executionCount++;
|
|
|
+ this.saveExecutionCount();
|
|
|
+ this.updatePrompt();
|
|
|
+
|
|
|
+ this.scrollToBottom();
|
|
|
+ } catch (e) {
|
|
|
+ console.log('Plugin execution error:', e);
|
|
|
+ this.addOutput(`插件执行错误: ${e.message}`, 'error');
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 将用户输入转换为 Symja 可运算的类型
|
|
|
+ convertInputToSymjaType(value) {
|
|
|
+ console.log(`convertInputToSymjaType called with: "${value}"`);
|
|
|
+
|
|
|
+ if (!value || typeof value !== 'string') {
|
|
|
+ console.log('Value is not a string, returning as-is');
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+
|
|
|
+ const trimmed = value.trim();
|
|
|
+
|
|
|
+ // 空值直接返回
|
|
|
+ if (trimmed === '') {
|
|
|
+ console.log('Value is empty, returning as-is');
|
|
|
+ return trimmed;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否是单个变量名(字母开头,后跟字母数字下划线)或 % 符号
|
|
|
+ const varNameMatch = trimmed.match(/^([a-zA-Z]\w*|%)$/);
|
|
|
+ console.log(`Variable name match result:`, varNameMatch);
|
|
|
+
|
|
|
+ if (varNameMatch) {
|
|
|
+ // 直接尝试求值,如果是已定义的变量会返回值,否则返回变量名本身
|
|
|
+ try {
|
|
|
+ const evalResult = Android.symjaEval(trimmed);
|
|
|
+ console.log(`Evaluated "${trimmed}" to: "${evalResult}"`);
|
|
|
+
|
|
|
+ // 如果求值结果与输入不同,说明变量已定义
|
|
|
+ if (evalResult !== trimmed) {
|
|
|
+ console.log(`Variable ${trimmed} is defined, using value: ${evalResult}`);
|
|
|
+ return evalResult;
|
|
|
+ } else {
|
|
|
+ console.log(`Variable ${trimmed} is not defined or equals itself`);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error(`Failed to evaluate ${trimmed}:`, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 尝试通过 Symja 验证,如果能成功解析说明是有效的表达式
|
|
|
+ console.log(`Trying to validate as Symja expression: "${trimmed}"`);
|
|
|
+ try {
|
|
|
+ Android.symjaEval(trimmed);
|
|
|
+ console.log(`Valid Symja expression, returning: "${trimmed}"`);
|
|
|
+ return trimmed;
|
|
|
+ } catch (e) {
|
|
|
+ // 如果无法解析为 Symja 表达式,返回原值
|
|
|
+ // 插件内部可以根据需要进一步处理
|
|
|
+ console.log(`Not a valid Symja expression, returning as literal: "${trimmed}"`);
|
|
|
+ return trimmed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ createIJSAPI(pluginOutputs) {
|
|
|
+ const self = this;
|
|
|
+ // 闭包引用 — IJS.input.xy() 需要访问 canvas 对象
|
|
|
+ const canvasRef = { _canvas: null, _logicalW: 600, _logicalH: 800 };
|
|
|
+ return {
|
|
|
+ // ── IJS.Symja 数学引擎 ─────────────────────────
|
|
|
+ Symja: {
|
|
|
+ print: function(message) {
|
|
|
+ pluginOutputs.push(String(message));
|
|
|
+ },
|
|
|
+ eval: function(expression) {
|
|
|
+ try { return Android.symjaEval(expression); }
|
|
|
+ catch (e) { return '错误: 无法调用 Symja'; }
|
|
|
+ },
|
|
|
+ differentiate: function(expr, variable) { return this.eval(`D[${expr}, ${variable}]`); },
|
|
|
+ integrate: function(expr, variable) { return this.eval(`Integrate[${expr}, ${variable}]`); },
|
|
|
+ limit: function(expr, variable, point, direction) {
|
|
|
+ try { return Android.limit(expr, variable, point, direction || ''); } catch (e) { return '错误:' + e.message; }
|
|
|
+ },
|
|
|
+ series: function(expr, variable, point, order) {
|
|
|
+ try { return Android.series(expr, variable, point, order || 5); } catch (e) { return '错误:' + e.message; }
|
|
|
+ },
|
|
|
+ solve: function(equation, variable) { return this.eval(`Solve[${equation}, ${variable}]`); },
|
|
|
+ simplify: function(expr) { return this.eval(`Simplify[${expr}]`); },
|
|
|
+ expand: function(expr) { return this.eval(`Expand[${expr}]`); },
|
|
|
+ factor: function(expr) { return this.eval(`Factor[${expr}]`); },
|
|
|
+ apart: function(expr, variable) { try { return Android.apart(expr, variable); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ together: function(expr) { try { return Android.together(expr); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ trigSimplify: function(expr) { try { return Android.trigSimplify(expr); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ sum: function(expr, variable, start, end) {
|
|
|
+ try { return Android.sum(expr, variable, String(start), String(end)); } catch (e) { return '错误:' + e.message; }
|
|
|
+ },
|
|
|
+ product: function(expr, variable, start, end) {
|
|
|
+ try { return Android.product(expr, variable, String(start), String(end)); } catch (e) { return '错误:' + e.message; }
|
|
|
+ },
|
|
|
+ matrix: {
|
|
|
+ det: function(matrix) { try { return Android.matrixOp('Det', matrix); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ inverse: function(matrix) { try { return Android.matrixOp('Inverse', matrix); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ transpose: function(matrix) { try { return Android.matrixOp('Transpose', matrix); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ eigenvalues: function(matrix) { try { return Android.matrixOp('Eigenvalues', matrix); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ rank: function(matrix) { try { return Android.matrixOp('MatrixRank', matrix); } catch (e) { return '错误:' + e.message; } }
|
|
|
+ },
|
|
|
+ gcd: function(a, b) { try { return Android.gcd(String(a), String(b)); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ lcm: function(a, b) { try { return Android.lcm(String(a), String(b)); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ factorInteger: function(n) { try { return Android.factorInteger(String(n)); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ isPrime: function(n) { try { return Android.isPrime(String(n)); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ fibonacci: function(n) { try { return Android.fibonacci(n); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ binomial: function(n, k) { try { return Android.binomial(String(n), String(k)); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ numeric: function(expr, precision) {
|
|
|
+ try { return Android.numericEval(expr, precision || 16); } catch (e) { return '错误:' + e.message; }
|
|
|
+ },
|
|
|
+ plot: function(func, variable, xMin, xMax) {
|
|
|
+ try { return Android.evaluatePlot('Plot[' + func + ', {' + variable + ', ' + xMin + ', ' + xMax + '}]'); } catch (e) { return 'Error:' + e.message; }
|
|
|
+ },
|
|
|
+ parametricPlot: function(xf, yf, t, tMin, tMax) {
|
|
|
+ try { return Android.evaluatePlot('ParametricPlot[{' + xf + ', ' + yf + '}, {' + t + ', ' + tMin + ', ' + tMax + '}]'); } catch (e) { return 'Error:' + e.message; }
|
|
|
+ },
|
|
|
+ listPlot: function(points) {
|
|
|
+ try { return Android.evaluatePlot('ListPlot[' + points + ']'); } catch (e) { return 'Error:' + e.message; }
|
|
|
+ },
|
|
|
+ toTeX: function(expr) { try { return Android.toTeX(expr); } catch (e) { return '错误:' + e.message; } },
|
|
|
+ renderLatex: function(latex) { symjaConsole.addOutput(latex, 'result', { renderLatex: true }); return '已渲染 LaTeX'; }
|
|
|
+ },
|
|
|
+
|
|
|
+ // ── IJS.canvas 绘图 ────────────────────────────
|
|
|
+ canvas: {
|
|
|
+ _ctx: null,
|
|
|
+ _canvas: null,
|
|
|
+ _frameId: null,
|
|
|
+ create: function(w, h) {
|
|
|
+ const panel = document.getElementById('fullscreenContent');
|
|
|
+ if (!panel) return null;
|
|
|
+ panel.innerHTML = '';
|
|
|
+ const c = document.createElement('canvas');
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
+ // 自适应:未指定尺寸则填满全屏面板
|
|
|
+ const cw = w || panel.clientWidth || window.innerWidth;
|
|
|
+ const ch = h || panel.clientHeight || window.innerHeight;
|
|
|
+ c.width = cw * dpr;
|
|
|
+ c.height = ch * dpr;
|
|
|
+ c.style.width = cw + 'px';
|
|
|
+ c.style.height = ch + 'px';
|
|
|
+ c.style.maxWidth = '100%';
|
|
|
+ c.style.maxHeight = '100%';
|
|
|
+ this._canvas = c;
|
|
|
+ this._ctx = c.getContext('2d');
|
|
|
+ this._ctx.scale(dpr, dpr);
|
|
|
+ this._logicalW = cw;
|
|
|
+ this._logicalH = ch;
|
|
|
+ // 同步给 input.xy() 的闭包引用
|
|
|
+ canvasRef._canvas = c;
|
|
|
+ canvasRef._logicalW = cw;
|
|
|
+ canvasRef._logicalH = ch;
|
|
|
+ panel.appendChild(c);
|
|
|
+ return this._ctx;
|
|
|
+ },
|
|
|
+ /** 获取逻辑画布宽度(供插件做自适应布局) */
|
|
|
+ width: function() { return this._logicalW || 600; },
|
|
|
+ /** 获取逻辑画布高度 */
|
|
|
+ height: function() { return this._logicalH || 800; },
|
|
|
+ getContext: function() { return this._ctx; },
|
|
|
+ getSize: function() {
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
+ return this._canvas ? { width: this._canvas.width / dpr, height: this._canvas.height / dpr } : { width: 0, height: 0 };
|
|
|
+ },
|
|
|
+ clear: function() {
|
|
|
+ if (this._ctx && this._canvas) {
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
+ this._ctx.clearRect(0, 0, this._canvas.width / dpr, this._canvas.height / dpr);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ fill: function(color) {
|
|
|
+ if (this._ctx && this._canvas) {
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
+ this._ctx.fillStyle = color || '#FFFFFF';
|
|
|
+ this._ctx.fillRect(0, 0, this._canvas.width / dpr, this._canvas.height / dpr);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ size: function(w, h) {
|
|
|
+ if (this._canvas) {
|
|
|
+ this._canvas.width = w;
|
|
|
+ this._canvas.height = h;
|
|
|
+ this._logicalW = w;
|
|
|
+ this._logicalH = h;
|
|
|
+ canvasRef._logicalW = w;
|
|
|
+ canvasRef._logicalH = h;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // 动画循环
|
|
|
+ loop: function(callback) {
|
|
|
+ const selfCanvas = this;
|
|
|
+ function frame() {
|
|
|
+ selfCanvas._frameId = requestAnimationFrame(frame);
|
|
|
+ callback();
|
|
|
+ }
|
|
|
+ this.stop();
|
|
|
+ selfCanvas._frameId = requestAnimationFrame(frame);
|
|
|
+ },
|
|
|
+ stop: function() {
|
|
|
+ if (this._frameId) {
|
|
|
+ cancelAnimationFrame(this._frameId);
|
|
|
+ this._frameId = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // ── IJS.input 触摸交互 ──────────────────────────
|
|
|
+ input: {
|
|
|
+ _callbacks: {},
|
|
|
+ onTouchStart: function(cb) { this._callbacks.touchstart = cb; },
|
|
|
+ onTouchMove: function(cb) { this._callbacks.touchmove = cb; },
|
|
|
+ onTouchEnd: function(cb) { this._callbacks.touchend = cb; this._bind(); },
|
|
|
+ /** 模拟触摸(MCP 调试用) */
|
|
|
+ _fireTouch: function(x, y) {
|
|
|
+ if (this._callbacks.touchend) {
|
|
|
+ this._callbacks.touchend({
|
|
|
+ changedTouches: [{clientX: x, clientY: y}],
|
|
|
+ preventDefault: function(){}
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 将触摸事件的 clientX/Y 转换为画布逻辑坐标 */
|
|
|
+ xy: function(e) {
|
|
|
+ var touch = e.changedTouches ? e.changedTouches[0] : (e.touches ? e.touches[0] : e);
|
|
|
+ if (!touch) return {x:0,y:0};
|
|
|
+ var c = canvasRef._canvas;
|
|
|
+ if (!c) return {x: touch.clientX, y: touch.clientY};
|
|
|
+ var r = c.getBoundingClientRect();
|
|
|
+ return {
|
|
|
+ x: (touch.clientX - r.left) * (canvasRef._logicalW / r.width),
|
|
|
+ y: (touch.clientY - r.top) * (canvasRef._logicalH / r.height)
|
|
|
+ };
|
|
|
+ },
|
|
|
+ _bound: false,
|
|
|
+ _bind: function() {
|
|
|
+ if (this._bound) return;
|
|
|
+ this._bound = true;
|
|
|
+ const panel = document.getElementById('fullscreenContent');
|
|
|
+ if (!panel) return;
|
|
|
+ const selfInput = this;
|
|
|
+ panel.addEventListener('touchstart', function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ if (selfInput._callbacks.touchstart) selfInput._callbacks.touchstart(e);
|
|
|
+ }, { passive: false });
|
|
|
+ panel.addEventListener('touchmove', function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ if (selfInput._callbacks.touchmove) selfInput._callbacks.touchmove(e);
|
|
|
+ }, { passive: false });
|
|
|
+ panel.addEventListener('touchend', function(e) {
|
|
|
+ if (selfInput._callbacks.touchend) selfInput._callbacks.touchend(e);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // ── IJS.io 文件读写 (应用数据目录) ────────────────
|
|
|
+ io: {
|
|
|
+ read: function(filename) {
|
|
|
+ try { return Android.ioRead(filename); } catch (e) { return null; }
|
|
|
+ },
|
|
|
+ write: function(filename, content) {
|
|
|
+ try { Android.ioWrite(filename, content); return true; } catch (e) { return false; }
|
|
|
+ },
|
|
|
+ list: function() {
|
|
|
+ try { return JSON.parse(Android.ioList()); } catch (e) { return []; }
|
|
|
+ },
|
|
|
+ delete: function(filename) {
|
|
|
+ try { Android.ioDelete(filename); return true; } catch (e) { return false; }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // ── IJS.audio 音效 ──────────────────────────────
|
|
|
+ audio: {
|
|
|
+ _ctx: null,
|
|
|
+ _getCtx: function() {
|
|
|
+ if (!this._ctx) this._ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
+ return this._ctx;
|
|
|
+ },
|
|
|
+ beep: function(freq, duration, type) {
|
|
|
+ try {
|
|
|
+ const ctx = this._getCtx();
|
|
|
+ const osc = ctx.createOscillator();
|
|
|
+ const gain = ctx.createGain();
|
|
|
+ osc.type = type || 'square';
|
|
|
+ osc.frequency.value = freq || 440;
|
|
|
+ gain.gain.value = 0.15;
|
|
|
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + (duration || 0.2));
|
|
|
+ osc.connect(gain);
|
|
|
+ gain.connect(ctx.destination);
|
|
|
+ osc.start(ctx.currentTime);
|
|
|
+ osc.stop(ctx.currentTime + (duration || 0.2));
|
|
|
+ } catch(e) {}
|
|
|
+ },
|
|
|
+ playNote: function(note, octave, duration) {
|
|
|
+ const notes = { C: 262, D: 294, E: 330, F: 349, G: 392, A: 440, B: 494 };
|
|
|
+ const freq = (notes[note] || 440) * Math.pow(2, (octave || 4) - 4);
|
|
|
+ this.beep(freq, duration || 0.2, 'square');
|
|
|
+ },
|
|
|
+ playMelody: function(melody, tempo) {
|
|
|
+ let t = 0;
|
|
|
+ const beat = (tempo || 120) / 60;
|
|
|
+ const selfAudio = this;
|
|
|
+ melody.forEach(function(m) {
|
|
|
+ if (m.note === 'R') { t += m.dur / beat; return; }
|
|
|
+ setTimeout(function() {
|
|
|
+ selfAudio.playNote(m.note, m.octave || 4, m.dur / beat * 0.9);
|
|
|
+ }, t * 1000);
|
|
|
+ t += m.dur / beat;
|
|
|
+ });
|
|
|
+ },
|
|
|
+ playFile: function(filename) {
|
|
|
+ // 暂不支持,留待扩展
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // ── IJS.ui 面板控制 ─────────────────────────────
|
|
|
+ ui: {
|
|
|
+ close: function() {
|
|
|
+ document.getElementById('fullscreenPanel').classList.remove('show');
|
|
|
+ },
|
|
|
+ setBackground: function(color) {
|
|
|
+ const panel = document.getElementById('fullscreenContent');
|
|
|
+ if (panel) panel.style.background = color;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ trackVariables(expression) {
|
|
|
+ // 简单的变量赋值检测
|
|
|
+ const match = expression.match(/^([a-zA-Z]\w*)\s*=/);
|
|
|
+ if (match) {
|
|
|
+ this.variables.add(match[1]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ clearVariable(varName) {
|
|
|
+ try {
|
|
|
+ const result = Android.clearVariable(varName);
|
|
|
+ this.addOutput(result, 'help');
|
|
|
+ this.variables.delete(varName);
|
|
|
+ } catch (e) {
|
|
|
+ this.addOutput(`❌ 清除失败: ${e.message}`, 'error');
|
|
|
+ }
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+
|
|
|
+ showHistory() {
|
|
|
+ if (this.history.length === 0) {
|
|
|
+ this.addOutput('📜 历史记录为空', 'help');
|
|
|
+ } else {
|
|
|
+ const recent = this.history.slice(-10).reverse();
|
|
|
+ const historyText = recent.map((cmd, i) => `${recent.length - i}. ${cmd}`).join('\n');
|
|
|
+ this.addOutput(`📜 最近的命令:\n${historyText}`, 'help');
|
|
|
+ }
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+
|
|
|
+ showMcpAddress() {
|
|
|
+ try {
|
|
|
+ const running = typeof Android !== 'undefined' && Android.isMcpRunning && Android.isMcpRunning();
|
|
|
+ const port = '8734';
|
|
|
+ const status = running ? '✅ 运行中' : '⭕ 未启动';
|
|
|
+ const rikkaConfig = JSON.stringify({
|
|
|
+ mcpServers: {
|
|
|
+ ijs: {
|
|
|
+ type: "streamable-http",
|
|
|
+ url: `http://127.0.0.1:${port}/mcp`,
|
|
|
+ commonOptions: {
|
|
|
+ name: "IJS - Symja 符号计算",
|
|
|
+ enable: true,
|
|
|
+ headers: []
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, null, 2);
|
|
|
+ this.addOutput(`🌐 MCP 服务器 ${status}\n\n📍 地址: http://127.0.0.1:${port}/mcp\n\n📋 RikkaHub 配置:\n${rikkaConfig}\n\n💻 Claude Desktop 配置:\n{"mcpServers":{"ijs":{"url":"http://127.0.0.1:${port}/mcp"}}}\n\n🔧 adb 转发: adb forward tcp:${port} tcp:${port}`, 'help');
|
|
|
+ } catch(e) {
|
|
|
+ this.addOutput('🌐 MCP 服务器\n 地址: http://127.0.0.1:8734/mcp', 'help');
|
|
|
+ }
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+
|
|
|
+ showFeedback() {
|
|
|
+ const email = 'nonvioce@qq.com';
|
|
|
+ const feedbackId = 'feedback-' + Date.now();
|
|
|
+
|
|
|
+ // 创建带复制按钮的反馈信息
|
|
|
+ const line = document.createElement('div');
|
|
|
+ line.className = 'terminal-line';
|
|
|
+ line.innerHTML = `
|
|
|
+ <div class="feedback-line">
|
|
|
+ <div class="feedback-icon">📧</div>
|
|
|
+ <div class="feedback-content">
|
|
|
+ <div class="feedback-title">反馈与建议</div>
|
|
|
+ <div class="feedback-text">
|
|
|
+ 欢迎通过邮件向我们反馈问题或提出建议!
|
|
|
+ </div>
|
|
|
+ <div class="feedback-email-container">
|
|
|
+ <span class="feedback-email">${email}</span>
|
|
|
+ <button class="feedback-copy-btn" onclick="symjaConsole.copyFeedbackEmail('${email}', this)">
|
|
|
+ 📋 复制邮箱
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ this.output.appendChild(line);
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+
|
|
|
+ copyFeedbackEmail(email, button) {
|
|
|
+ // 创建临时文本区域
|
|
|
+ const textarea = document.createElement('textarea');
|
|
|
+ textarea.value = email;
|
|
|
+ textarea.style.position = 'fixed';
|
|
|
+ textarea.style.opacity = '0';
|
|
|
+ document.body.appendChild(textarea);
|
|
|
+ textarea.select();
|
|
|
+
|
|
|
+ try {
|
|
|
+ document.execCommand('copy');
|
|
|
+ button.textContent = '✅ 已复制';
|
|
|
+ button.style.background = '#4CAF50';
|
|
|
+ setTimeout(() => {
|
|
|
+ button.textContent = '📋 复制邮箱';
|
|
|
+ button.style.background = '';
|
|
|
+ }, 2000);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Copy failed:', e);
|
|
|
+ button.textContent = '❌ 复制失败';
|
|
|
+ setTimeout(() => {
|
|
|
+ button.textContent = '📋 复制邮箱';
|
|
|
+ }, 2000);
|
|
|
+ }
|
|
|
+
|
|
|
+ document.body.removeChild(textarea);
|
|
|
+ }
|
|
|
+
|
|
|
+ addToHistory(cmd) {
|
|
|
+ // 避免重复的连续命令
|
|
|
+ if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) {
|
|
|
+ this.history.push(cmd);
|
|
|
+ // 限制历史记录大小
|
|
|
+ if (this.history.length > 100) {
|
|
|
+ this.history.shift();
|
|
|
+ }
|
|
|
+ this.saveHistory();
|
|
|
+ }
|
|
|
+ this.historyIndex = this.history.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ loadHistory() {
|
|
|
+ try {
|
|
|
+ if (typeof Android !== 'undefined' && Android.loadHistory) {
|
|
|
+ const saved = Android.loadHistory();
|
|
|
+ return saved ? JSON.parse(saved) : [];
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ // 回退到 localStorage
|
|
|
+ try {
|
|
|
+ const saved = localStorage.getItem('symja_history');
|
|
|
+ return saved ? JSON.parse(saved) : [];
|
|
|
+ } catch (e) { return []; }
|
|
|
+ }
|
|
|
+
|
|
|
+ saveHistory() {
|
|
|
+ const json = JSON.stringify(this.history);
|
|
|
+ try {
|
|
|
+ if (typeof Android !== 'undefined' && Android.saveHistory) {
|
|
|
+ Android.saveHistory(json);
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ try {
|
|
|
+ localStorage.setItem('symja_history', json);
|
|
|
+ } catch (e) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ saveSession(name) {
|
|
|
+ try {
|
|
|
+ const session = {
|
|
|
+ history: this.history,
|
|
|
+ variables: Array.from(this.variables),
|
|
|
+ timestamp: new Date().toISOString()
|
|
|
+ };
|
|
|
+ localStorage.setItem(`symja_session_${name}`, JSON.stringify(session));
|
|
|
+ this.addOutput(`💾 会话已保存: ${name}`, 'help');
|
|
|
+ } catch (e) {
|
|
|
+ this.addOutput(`❌ 保存失败: ${e.message}`, 'error');
|
|
|
+ }
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+
|
|
|
+ addOutput(text, type, options = {}) {
|
|
|
+ const line = document.createElement('div');
|
|
|
+ line.className = 'terminal-line';
|
|
|
+
|
|
|
+ if (type === 'input') {
|
|
|
+ // 命令输入显示(带计数器)
|
|
|
+ const count = options.count !== undefined ? options.count : '';
|
|
|
+ line.innerHTML = `
|
|
|
+ <div class="command-line">
|
|
|
+ <span class="command-prompt">[${count}]:</span>
|
|
|
+ <span class="command-text">${this.escapeHtml(text)}</span>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ } else if (type === 'result') {
|
|
|
+ const isError = text.startsWith('错误:');
|
|
|
+
|
|
|
+ // 检查是否需要 LaTeX 渲染
|
|
|
+ if (options.renderLatex && !isError) {
|
|
|
+ const count = options.count !== undefined ? options.count : '';
|
|
|
+ line.innerHTML = `
|
|
|
+ <div class="latex-line">
|
|
|
+ <div class="result-prompt">[${count}]:</div>
|
|
|
+ <div class="latex-content">
|
|
|
+ <div class="latex-rendered" id="latex-${Date.now()}"></div>
|
|
|
+ <div class="latex-source" style="display: none;">${this.escapeHtml(text)}</div>
|
|
|
+ <div class="latex-actions">
|
|
|
+ <button class="latex-btn latex-toggle" onclick="symjaConsole.toggleLatexSource(this)">显示源码</button>
|
|
|
+ <button class="latex-btn latex-copy" onclick="symjaConsole.copyLatex(this)">复制 LaTeX</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ this.output.appendChild(line);
|
|
|
+
|
|
|
+ // 渲染 LaTeX
|
|
|
+ const renderedDiv = line.querySelector('.latex-rendered');
|
|
|
+ this.renderLatex(text, renderedDiv);
|
|
|
+ } else {
|
|
|
+ const className = isError ? 'error-line' : 'result-line';
|
|
|
+ const count = options.count !== undefined ? options.count : '';
|
|
|
+ line.innerHTML = `
|
|
|
+ <div class="${className}">
|
|
|
+ <span class="result-prompt">[${count}]:</span>
|
|
|
+ <span class="result-text">${this.escapeHtml(text)}</span>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+ } else if (type === 'help') {
|
|
|
+ line.innerHTML = `<div class="info-line">${this.escapeHtml(text)}</div>`;
|
|
|
+ } else if (type === 'error') {
|
|
|
+ line.innerHTML = `<div class="error-line">${this.escapeHtml(text)}</div>`;
|
|
|
+ } else if (type === 'plugin-info') {
|
|
|
+ // 插件的控制台输出(通过 Symja.print() 输出的内容)
|
|
|
+ line.innerHTML = `<div class="plugin-info-line">${this.escapeHtml(text)}</div>`;
|
|
|
+ } else if (type === 'plugin') {
|
|
|
+ // 插件的返回值(可运算结果)
|
|
|
+ line.innerHTML = `<div class="plugin-line">${this.escapeHtml(text)}</div>`;
|
|
|
+ } else if (type === 'mcp-input') {
|
|
|
+ const seq = options.seq || '';
|
|
|
+ line.innerHTML = `<div class="mcp-input-line">
|
|
|
+ <span class="mcp-badge">🌐 MCP</span>
|
|
|
+ <span class="mcp-prompt">[${seq}]:</span>
|
|
|
+ <span class="mcp-command-text">${this.escapeHtml(text)}</span>
|
|
|
+ </div>`;
|
|
|
+ } else if (type === 'mcp-latex') {
|
|
|
+ line.innerHTML = `<div class="latex-line">
|
|
|
+ <div class="latex-content">
|
|
|
+ <div class="latex-rendered" id="latex-mcp-${Date.now()}"></div>
|
|
|
+ <div class="latex-source" style="display:none">${this.escapeHtml(text)}</div>
|
|
|
+ <div class="latex-actions">
|
|
|
+ <button class="latex-btn latex-toggle" onclick="symjaConsole.toggleLatexSource(this)">显示源码</button>
|
|
|
+ <button class="latex-btn latex-copy" onclick="symjaConsole.copyLatex(this)">复制 LaTeX</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+ this.output.appendChild(line);
|
|
|
+ const renderedDiv = line.querySelector('.latex-rendered');
|
|
|
+ this.renderLatex(text, renderedDiv);
|
|
|
+ } else if (type === 'mcp-result') {
|
|
|
+ line.innerHTML = `<div class="mcp-result-line">
|
|
|
+ <span class="mcp-prompt"></span>
|
|
|
+ <span class="mcp-result-text">${this.escapeHtml(text)}</span>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.output.appendChild(line);
|
|
|
+ }
|
|
|
+
|
|
|
+ // LaTeX 渲染功能
|
|
|
+ renderLatex(latex, element) {
|
|
|
+ try {
|
|
|
+ // 等待 KaTeX 加载
|
|
|
+ if (typeof katex !== 'undefined') {
|
|
|
+ // 清理 LaTeX 字符串
|
|
|
+ let cleanLatex = latex.trim();
|
|
|
+
|
|
|
+ // 如果是 Symja 的 TeXForm 输出,可能需要一些转换
|
|
|
+ if (cleanLatex.startsWith('TeXForm:')) {
|
|
|
+ cleanLatex = cleanLatex.substring(8).trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 使用 KaTeX 渲染
|
|
|
+ katex.render(cleanLatex, element, {
|
|
|
+ throwOnError: false,
|
|
|
+ displayMode: true,
|
|
|
+ output: 'html',
|
|
|
+ trust: false,
|
|
|
+ strict: false
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ // KaTeX 未加载,显示格式化的源码
|
|
|
+ element.style.fontFamily = 'var(--font-mono)';
|
|
|
+ element.style.fontSize = '14px';
|
|
|
+ element.style.padding = '12px';
|
|
|
+ element.style.background = '#F5F5F5';
|
|
|
+ element.style.borderRadius = '4px';
|
|
|
+ element.style.overflowX = 'auto';
|
|
|
+ element.textContent = latex;
|
|
|
+ console.warn('KaTeX not loaded, showing source code');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 渲染失败,显示源码
|
|
|
+ element.style.fontFamily = 'var(--font-mono)';
|
|
|
+ element.style.fontSize = '14px';
|
|
|
+ element.style.padding = '12px';
|
|
|
+ element.style.background = '#F5F5F5';
|
|
|
+ element.style.borderRadius = '4px';
|
|
|
+ element.style.overflowX = 'auto';
|
|
|
+ element.textContent = latex;
|
|
|
+ console.error('LaTeX rendering error:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 切换显示 LaTeX 源码
|
|
|
+ toggleLatexSource(button) {
|
|
|
+ const parent = button.closest('.latex-line');
|
|
|
+ if (!parent) return;
|
|
|
+
|
|
|
+ const source = parent.querySelector('.latex-source');
|
|
|
+ if (!source) return;
|
|
|
+
|
|
|
+ if (source.style.display === 'none') {
|
|
|
+ source.style.display = 'block';
|
|
|
+ button.textContent = '隐藏源码';
|
|
|
+ } else {
|
|
|
+ source.style.display = 'none';
|
|
|
+ button.textContent = '显示源码';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 复制 LaTeX 代码
|
|
|
+ copyLatex(button) {
|
|
|
+ const parent = button.closest('.latex-line');
|
|
|
+ if (!parent) return;
|
|
|
+
|
|
|
+ const source = parent.querySelector('.latex-source');
|
|
|
+ if (!source) return;
|
|
|
+
|
|
|
+ const text = source.textContent;
|
|
|
+
|
|
|
+ // 创建临时文本区域
|
|
|
+ const textarea = document.createElement('textarea');
|
|
|
+ textarea.value = text;
|
|
|
+ textarea.style.position = 'fixed';
|
|
|
+ textarea.style.opacity = '0';
|
|
|
+ document.body.appendChild(textarea);
|
|
|
+ textarea.select();
|
|
|
+
|
|
|
+ try {
|
|
|
+ document.execCommand('copy');
|
|
|
+ button.textContent = '已复制!';
|
|
|
+ setTimeout(() => {
|
|
|
+ button.textContent = '复制 LaTeX';
|
|
|
+ }, 2000);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Copy failed:', e);
|
|
|
+ }
|
|
|
+
|
|
|
+ document.body.removeChild(textarea);
|
|
|
+ }
|
|
|
+
|
|
|
+ showHelp() {
|
|
|
+ const helpPage = document.getElementById('helpPage');
|
|
|
+ const helpContent = document.getElementById('helpContent');
|
|
|
+
|
|
|
+ if (!helpPage || !helpContent) return;
|
|
|
+
|
|
|
+ // 隐藏键盘
|
|
|
+ this.hideKeyboard();
|
|
|
+
|
|
|
+ helpContent.innerHTML = `
|
|
|
+ <table class="help-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>说明</th>
|
|
|
+ <th>命令</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <tr><td>求导</td><td>D[x^2, x]</td></tr>
|
|
|
+ <tr><td>复合函数求导</td><td>D[Sin[x]*Cos[x], x]</td></tr>
|
|
|
+ <tr><td>不定积分</td><td>Integrate[x^2, x]</td></tr>
|
|
|
+ <tr><td>定积分</td><td>Integrate[x^2, {x, 0, 1}]</td></tr>
|
|
|
+ <tr><td>求极限</td><td>Limit[Sin[x]/x, x -> 0]</td></tr>
|
|
|
+ <tr><td>泰勒展开</td><td>Series[Exp[x], {x, 0, 5}]</td></tr>
|
|
|
+ <tr><td>求和</td><td>Sum[i^2, {i, 1, 10}]</td></tr>
|
|
|
+ <tr><td>求积(阶乘)</td><td>Product[i, {i, 1, 5}]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>展开</td><td>Expand[(x+1)^3]</td></tr>
|
|
|
+ <tr><td>因式分解</td><td>Factor[x^2-4]</td></tr>
|
|
|
+ <tr><td>化简</td><td>Simplify[Sin[x]^2+Cos[x]^2]</td></tr>
|
|
|
+ <tr><td>通分</td><td>Together[(1/x)+(1/y)]</td></tr>
|
|
|
+ <tr><td>部分分式</td><td>Apart[(x+1)/(x^2-1), x]</td></tr>
|
|
|
+ <tr><td>约分</td><td>Cancel[(x^2-1)/(x-1)]</td></tr>
|
|
|
+ <tr><td>合并同类项</td><td>Collect[x^2+3*x+2*x^2, x]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>解方程</td><td>Solve[x^2-5*x+6==0, x]</td></tr>
|
|
|
+ <tr><td>方程组</td><td>Solve[{x+y==1, x-y==3}, {x,y}]</td></tr>
|
|
|
+ <tr><td>数值求解</td><td>NSolve[x^3-2*x+1==0, x]</td></tr>
|
|
|
+ <tr><td>求根</td><td>Roots[x^2-4==0, x]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>行列式</td><td>Det[{{1,2},{3,4}}]</td></tr>
|
|
|
+ <tr><td>逆矩阵</td><td>Inverse[{{1,2},{3,4}}]</td></tr>
|
|
|
+ <tr><td>转置</td><td>Transpose[{{1,2},{3,4}}]</td></tr>
|
|
|
+ <tr><td>特征值</td><td>Eigenvalues[{{1,2},{3,4}}]</td></tr>
|
|
|
+ <tr><td>特征向量</td><td>Eigenvectors[{{1,2},{3,4}}]</td></tr>
|
|
|
+ <tr><td>矩阵秩</td><td>MatrixRank[{{1,2},{2,4}}]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>最大公约数</td><td>GCD[12, 18]</td></tr>
|
|
|
+ <tr><td>最小公倍数</td><td>LCM[12, 18]</td></tr>
|
|
|
+ <tr><td>质因数分解</td><td>FactorInteger[60]</td></tr>
|
|
|
+ <tr><td>判断素数</td><td>PrimeQ[17]</td></tr>
|
|
|
+ <tr><td>第 n 个素数</td><td>Prime[10]</td></tr>
|
|
|
+ <tr><td>斐波那契数</td><td>Fibonacci[10]</td></tr>
|
|
|
+ <tr><td>组合数 C(n,k)</td><td>Binomial[5, 2]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>高精度计算(50位)</td><td>N[Pi, 50]</td></tr>
|
|
|
+ <tr><td>数值化</td><td>N[Sqrt[2]]</td></tr>
|
|
|
+ <tr><td>四舍五入</td><td>Round[3.7]</td></tr>
|
|
|
+ <tr><td>向下取整</td><td>Floor[3.7]</td></tr>
|
|
|
+ <tr><td>向上取整</td><td>Ceiling[3.2]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>三角函数</td><td>Sin[x], Cos[x], Tan[x]</td></tr>
|
|
|
+ <tr><td>反三角函数</td><td>ArcSin[x], ArcCos[x], ArcTan[x]</td></tr>
|
|
|
+ <tr><td>双曲函数</td><td>Sinh[x], Cosh[x], Tanh[x]</td></tr>
|
|
|
+ <tr><td>三角展开</td><td>TrigExpand[Sin[2*x]]</td></tr>
|
|
|
+ <tr><td>三角化简</td><td>TrigReduce[Sin[x]^2]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>实部</td><td>Re[3+4*I]</td></tr>
|
|
|
+ <tr><td>虚部</td><td>Im[3+4*I]</td></tr>
|
|
|
+ <tr><td>模</td><td>Abs[3+4*I]</td></tr>
|
|
|
+ <tr><td>辐角</td><td>Arg[3+4*I]</td></tr>
|
|
|
+ <tr><td>共轭</td><td>Conjugate[3+4*I]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>生成表格</td><td>Table[i^2, {i, 1, 10}]</td></tr>
|
|
|
+ <tr><td>生成数列</td><td>Range[1, 10]</td></tr>
|
|
|
+ <tr><td>带步长的数列</td><td>Range[0, 1, 0.1]</td></tr>
|
|
|
+ <tr><td>列表长度</td><td>Length[{1,2,3,4}]</td></tr>
|
|
|
+ <tr><td>第一个元素</td><td>First[{1,2,3}]</td></tr>
|
|
|
+ <tr><td>最后一个元素</td><td>Last[{1,2,3}]</td></tr>
|
|
|
+ <tr><td>取前 n 个</td><td>Take[{1,2,3,4,5}, 3]</td></tr>
|
|
|
+ <tr><td>删除前 n 个</td><td>Drop[{1,2,3,4,5}, 2]</td></tr>
|
|
|
+ <tr><td>连接列表</td><td>Join[{1,2}, {3,4}]</td></tr>
|
|
|
+ <tr><td>反转列表</td><td>Reverse[{1,2,3}]</td></tr>
|
|
|
+ <tr><td>排序</td><td>Sort[{3,1,4,1,5}]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>替换规则</td><td>b+c /. b->1</td></tr>
|
|
|
+ <tr><td>变量替换</td><td>x^2+2*x+1 /. x->3</td></tr>
|
|
|
+ <tr><td>多重替换</td><td>expr /. {a->1, b->2}</td></tr>
|
|
|
+
|
|
|
+ <tr><td>微分方程</td><td>DSolve[y'[x]==y[x], y[x], x]</td></tr>
|
|
|
+ <tr><td>递推方程</td><td>RSolve[a[n]==2*a[n-1]+1, a[n], n]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>拉普拉斯变换</td><td>LaplaceTransform[Sin[t], t, s]</td></tr>
|
|
|
+ <tr><td>逆拉普拉斯变换</td><td>InverseLaplaceTransform[1/(s^2+1), s, t]</td></tr>
|
|
|
+ <tr><td>傅里叶变换</td><td>FourierTransform[Exp[-x^2], x, w]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>求最大值</td><td>Maximize[x^2-4*x+3, x]</td></tr>
|
|
|
+ <tr><td>求最小值</td><td>Minimize[x^2-4*x+3, x]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>平均值</td><td>Mean[{1,2,3,4,5}]</td></tr>
|
|
|
+ <tr><td>中位数</td><td>Median[{1,2,3,4,5}]</td></tr>
|
|
|
+ <tr><td>方差</td><td>Variance[{1,2,3,4,5}]</td></tr>
|
|
|
+ <tr><td>标准差</td><td>StandardDeviation[{1,2,3,4,5}]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>LaTeX 格式</td><td>TeXForm[x^2 + 2*x + 1]</td></tr>
|
|
|
+ <tr><td>完整形式</td><td>FullForm[x+y]</td></tr>
|
|
|
+ <tr><td>输入形式</td><td>InputForm[expr]</td></tr>
|
|
|
+
|
|
|
+ <tr><td>显示帮助</td><td>help 或 ?</td></tr>
|
|
|
+ <tr><td>清空输出</td><td>clear 或 cls</td></tr>
|
|
|
+ <tr><td>命令历史</td><td>history</td></tr>
|
|
|
+ <tr><td>清除变量</td><td>clear <变量></td></tr>
|
|
|
+ <tr><td>插件管理</td><td>plugins</td></tr>
|
|
|
+ <tr><td>显示反馈邮箱</td><td>feedback</td></tr>
|
|
|
+
|
|
|
+ <tr><td>圆周率 π</td><td>Pi</td></tr>
|
|
|
+ <tr><td>自然常数 e</td><td>E</td></tr>
|
|
|
+ <tr><td>虚数单位 i</td><td>I</td></tr>
|
|
|
+ <tr><td>无穷大 ∞</td><td>Infinity</td></tr>
|
|
|
+ <tr><td>布尔值</td><td>True, False</td></tr>
|
|
|
+ <tr><td>上一次的输出结果</td><td>%</td></tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ `;
|
|
|
+
|
|
|
+ helpPage.classList.add('show');
|
|
|
+ }
|
|
|
+
|
|
|
+ hideHelp() {
|
|
|
+ const helpPage = document.getElementById('helpPage');
|
|
|
+ if (helpPage) {
|
|
|
+ helpPage.classList.remove('show');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ showSettings() {
|
|
|
+ const panel = document.getElementById('settingsPanel');
|
|
|
+ if (!panel) return;
|
|
|
+ this.hideKeyboard();
|
|
|
+ document.getElementById('settingMcpEnabled').value = this.getMcpEnabled() ? '1' : '0';
|
|
|
+ document.getElementById('settingMcpPort').value = this.getSetting('mcpPort', '8734');
|
|
|
+ document.getElementById('settingFontSize').value = this.getSetting('fontSize', '14');
|
|
|
+ panel.classList.add('show');
|
|
|
+ }
|
|
|
+
|
|
|
+ hideSettings() {
|
|
|
+ document.getElementById('settingsPanel')?.classList.remove('show');
|
|
|
+ }
|
|
|
+
|
|
|
+ applySettings() {
|
|
|
+ const wasEnabled = this.getMcpEnabled();
|
|
|
+ const enabled = document.getElementById('settingMcpEnabled').value === '1';
|
|
|
+ const port = document.getElementById('settingMcpPort').value;
|
|
|
+ const fontSize = document.getElementById('settingFontSize').value;
|
|
|
+ this.saveSetting('mcpPort', port);
|
|
|
+ this.saveSetting('fontSize', fontSize);
|
|
|
+ this.setMcpEnabled(enabled);
|
|
|
+ document.querySelector('.terminal-output').style.fontSize = fontSize + 'px';
|
|
|
+ document.querySelector('.terminal-input').style.fontSize = fontSize + 'px';
|
|
|
+ this.hideSettings();
|
|
|
+ this.addOutput('⚙️ 设置已应用', 'help');
|
|
|
+ this.scrollToBottom();
|
|
|
+
|
|
|
+ // MCP 状态变更时更新轮询
|
|
|
+ if (enabled !== wasEnabled) {
|
|
|
+ if (enabled) {
|
|
|
+ this.startMcpPollingIfNeeded();
|
|
|
+ } else {
|
|
|
+ this.stopMcpPolling();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ getMcpEnabled() {
|
|
|
+ try {
|
|
|
+ if (typeof Android !== 'undefined' && Android.getMcpEnabled !== undefined) {
|
|
|
+ return Android.getMcpEnabled();
|
|
|
+ }
|
|
|
+ } catch(e) {}
|
|
|
+ return this.getSetting('mcpEnabled', '1') === '1';
|
|
|
+ }
|
|
|
+
|
|
|
+ setMcpEnabled(val) {
|
|
|
+ this.saveSetting('mcpEnabled', val ? '1' : '0');
|
|
|
+ try {
|
|
|
+ if (typeof Android !== 'undefined' && Android.setMcpEnabled) {
|
|
|
+ Android.setMcpEnabled(val);
|
|
|
+ }
|
|
|
+ } catch(e) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ startMcpPollingIfNeeded() {
|
|
|
+ if (!this.getMcpEnabled()) return;
|
|
|
+ if (!this.mcpPollTimer) {
|
|
|
+ this.mcpPollTimer = setInterval(() => this.pollMcpEvents(), 1000);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ stopMcpPolling() {
|
|
|
+ if (this.mcpPollTimer) { clearInterval(this.mcpPollTimer); this.mcpPollTimer = null; }
|
|
|
+ }
|
|
|
+
|
|
|
+ getSetting(key, def) {
|
|
|
+ try { return localStorage.getItem('ijs_' + key) || def; } catch(e) { return def; }
|
|
|
+ }
|
|
|
+
|
|
|
+ saveSetting(key, val) {
|
|
|
+ try { localStorage.setItem('ijs_' + key, val); } catch(e) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ loadSettings() {
|
|
|
+ const fs = this.getSetting('fontSize', '14');
|
|
|
+ try {
|
|
|
+ document.querySelector('.terminal-output').style.fontSize = fs + 'px';
|
|
|
+ document.querySelector('.terminal-input').style.fontSize = fs + 'px';
|
|
|
+ } catch(e) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ clear() {
|
|
|
+ this.output.innerHTML = '';
|
|
|
+ this.showWelcome();
|
|
|
+ // 重置计数器
|
|
|
+ this.executionCount = 1;
|
|
|
+ this.saveExecutionCount();
|
|
|
+ this.updatePrompt();
|
|
|
+ }
|
|
|
+
|
|
|
+ reset() {
|
|
|
+ try {
|
|
|
+ const result = Android.reset();
|
|
|
+ this.variables.clear();
|
|
|
+ this.addOutput('🔄 ' + result, 'help');
|
|
|
+ this.scrollToBottom();
|
|
|
+ // 重置计数器
|
|
|
+ this.executionCount = 1;
|
|
|
+ this.saveExecutionCount();
|
|
|
+ this.updatePrompt();
|
|
|
+ } catch (e) {
|
|
|
+ this.addOutput('错误: 无法重置计算器', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ updatePrompt() {
|
|
|
+ if (this.inputPrompt) {
|
|
|
+ this.inputPrompt.textContent = `[${this.executionCount}]:`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ loadExecutionCount() {
|
|
|
+ return 1; // 每次打开应用从1开始
|
|
|
+ }
|
|
|
+
|
|
|
+ saveExecutionCount() {
|
|
|
+ // 不持久化编号,每次打开都重置
|
|
|
+ }
|
|
|
+
|
|
|
+ navigateHistory(direction) {
|
|
|
+ if (this.history.length === 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保存当前输入
|
|
|
+ if (this.historyIndex === this.history.length) {
|
|
|
+ this.tempInput = this.input.value;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.historyIndex += direction;
|
|
|
+
|
|
|
+ if (this.historyIndex < 0) {
|
|
|
+ this.historyIndex = 0;
|
|
|
+ } else if (this.historyIndex >= this.history.length) {
|
|
|
+ this.historyIndex = this.history.length;
|
|
|
+ this.input.value = this.tempInput;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.input.value = this.history[this.historyIndex];
|
|
|
+ // 光标移到末尾
|
|
|
+ setTimeout(() => {
|
|
|
+ this.input.setSelectionRange(this.input.value.length, this.input.value.length);
|
|
|
+ }, 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 自动补全功能
|
|
|
+ updateCompletionList() {
|
|
|
+ const value = this.input.value;
|
|
|
+ const cursorPos = this.input.selectionStart;
|
|
|
+ const textBeforeCursor = value.substring(0, cursorPos);
|
|
|
+ const match = textBeforeCursor.match(/[A-Za-z0-9_-]+$/);
|
|
|
+
|
|
|
+ console.log('updateCompletionList:', { value, textBeforeCursor, match });
|
|
|
+
|
|
|
+ if (match && match[0].length >= 2) {
|
|
|
+ const prefix = match[0];
|
|
|
+ console.log('Searching completions for prefix:', prefix);
|
|
|
+
|
|
|
+ // 特殊命令列表
|
|
|
+ const specialCommands = ['help', 'clear', 'cls', 'history', 'plugins', 'mcp', 'feedback'];
|
|
|
+
|
|
|
+ // 获取插件命令列表
|
|
|
+ const pluginCommands = this.plugins
|
|
|
+ .filter(p => p.command && p.command.length > 0)
|
|
|
+ .map(p => p.command);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const completionsJson = Android.getCompletions(prefix);
|
|
|
+ console.log('Raw completions JSON:', completionsJson);
|
|
|
+
|
|
|
+ // 解析补全结果 - 可能是逗号分隔的字符串或 JSON 数组
|
|
|
+ let symjaCompletions = [];
|
|
|
+ if (completionsJson.startsWith('[')) {
|
|
|
+ // JSON 数组格式
|
|
|
+ symjaCompletions = JSON.parse(completionsJson);
|
|
|
+ } else {
|
|
|
+ // 逗号分隔的字符串格式
|
|
|
+ symjaCompletions = completionsJson.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加匹配的特殊命令
|
|
|
+ const lowerPrefix = prefix.toLowerCase();
|
|
|
+ const matchingCommands = specialCommands.filter(cmd => cmd.startsWith(lowerPrefix));
|
|
|
+
|
|
|
+ // 添加匹配的插件命令
|
|
|
+ const matchingPluginCommands = pluginCommands.filter(cmd => cmd.toLowerCase().startsWith(lowerPrefix));
|
|
|
+
|
|
|
+ // 合并并去重(插件命令优先显示)
|
|
|
+ this.completions = [...new Set([...matchingPluginCommands, ...matchingCommands, ...symjaCompletions])];
|
|
|
+
|
|
|
+ console.log('Parsed completions:', this.completions);
|
|
|
+
|
|
|
+ if (this.completions.length > 0) {
|
|
|
+ this.showCompletionList();
|
|
|
+ } else {
|
|
|
+ this.hideCompletionList();
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Completion error:', e);
|
|
|
+ this.completions = [];
|
|
|
+ this.hideCompletionList();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ this.completions = [];
|
|
|
+ this.hideCompletionList();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ showCompletionList() {
|
|
|
+ console.log('showCompletionList called');
|
|
|
+ console.log('completionList element:', this.completionList);
|
|
|
+ console.log('completions:', this.completions);
|
|
|
+
|
|
|
+ if (!this.completionList) {
|
|
|
+ console.error('completionList element not found');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.completions.length === 0) {
|
|
|
+ console.log('No completions to show');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.completionList.innerHTML = '';
|
|
|
+
|
|
|
+ // 最多显示 8 个补全项
|
|
|
+ const itemsToShow = this.completions.slice(0, 8);
|
|
|
+ console.log('Items to show:', itemsToShow);
|
|
|
+
|
|
|
+ itemsToShow.forEach(completion => {
|
|
|
+ const item = document.createElement('div');
|
|
|
+ item.className = 'completion-item';
|
|
|
+
|
|
|
+ // 检查是否是插件命令
|
|
|
+ const plugin = this.plugins.find(p => p.command === completion);
|
|
|
+ if (plugin) {
|
|
|
+ // 插件命令,显示图标和名称
|
|
|
+ item.innerHTML = `<span style="margin-right: 8px;">${plugin.icon}</span>${completion} <span style="color: #999; font-size: 12px; margin-left: 8px;">(${plugin.name})</span>`;
|
|
|
+ } else {
|
|
|
+ // 普通命令或函数
|
|
|
+ item.textContent = completion;
|
|
|
+ }
|
|
|
+
|
|
|
+ item.addEventListener('click', () => {
|
|
|
+ this.applyCompletion(completion);
|
|
|
+ });
|
|
|
+ this.completionList.appendChild(item);
|
|
|
+ });
|
|
|
+
|
|
|
+ this.completionList.classList.add('show');
|
|
|
+ console.log('Completion list should now be visible');
|
|
|
+ }
|
|
|
+
|
|
|
+ hideCompletionList() {
|
|
|
+ if (this.completionList) {
|
|
|
+ this.completionList.classList.remove('show');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ applyCompletion(completion) {
|
|
|
+ const value = this.input.value;
|
|
|
+ const cursorPos = this.input.selectionStart;
|
|
|
+ const textBeforeCursor = value.substring(0, cursorPos);
|
|
|
+ const textAfterCursor = value.substring(cursorPos);
|
|
|
+
|
|
|
+ const match = textBeforeCursor.match(/[A-Za-z0-9_-]+$/);
|
|
|
+ if (match) {
|
|
|
+ const prefix = match[0];
|
|
|
+ const beforePrefix = textBeforeCursor.substring(0, textBeforeCursor.length - prefix.length);
|
|
|
+
|
|
|
+ // 检查是否是插件命令
|
|
|
+ const plugin = this.plugins.find(p => p.command === completion);
|
|
|
+ if (plugin && plugin.inputs && plugin.inputs.length > 0) {
|
|
|
+ // 插件命令,添加参数括号和提示
|
|
|
+ const paramHints = plugin.inputs.map(input => input.name).join(', ');
|
|
|
+ this.input.value = beforePrefix + completion + '[' + paramHints + ']' + textAfterCursor;
|
|
|
+
|
|
|
+ // 将光标放在第一个参数位置
|
|
|
+ const newCursorPos = beforePrefix.length + completion.length + 1;
|
|
|
+ this.input.setSelectionRange(newCursorPos, newCursorPos + plugin.inputs[0].name.length);
|
|
|
+ } else {
|
|
|
+ // 普通命令或函数
|
|
|
+ this.input.value = beforePrefix + completion + textAfterCursor;
|
|
|
+
|
|
|
+ const newCursorPos = beforePrefix.length + completion.length;
|
|
|
+ this.input.setSelectionRange(newCursorPos, newCursorPos);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.hideCompletionList();
|
|
|
+ this.input.focus();
|
|
|
+ }
|
|
|
+
|
|
|
+ handleAutoComplete() {
|
|
|
+ // 保留这个方法以防其他地方调用,但不再使用
|
|
|
+ this.updateCompletionList();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 插件相关功能
|
|
|
+ loadPlugins() {
|
|
|
+ try {
|
|
|
+ const pluginsJson = Android.getPlugins();
|
|
|
+ console.log('Loaded plugins JSON:', pluginsJson);
|
|
|
+ this.plugins = JSON.parse(pluginsJson);
|
|
|
+ console.log('Parsed plugins count:', this.plugins.length);
|
|
|
+ console.log('Plugins:', this.plugins);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Failed to load plugins:', e);
|
|
|
+ this.plugins = [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ showPluginPanel() {
|
|
|
+ this.loadPlugins();
|
|
|
+ const panel = document.getElementById('pluginPanel');
|
|
|
+
|
|
|
+ if (!panel) return;
|
|
|
+
|
|
|
+ // 隐藏键盘
|
|
|
+ this.hideKeyboard();
|
|
|
+
|
|
|
+ // 确保默认显示插件列表标签
|
|
|
+ const tabs = panel.querySelectorAll('.plugin-tab');
|
|
|
+ const contents = panel.querySelectorAll('.tab-content');
|
|
|
+
|
|
|
+ tabs.forEach(tab => {
|
|
|
+ if (tab.dataset.tab === 'list') {
|
|
|
+ tab.classList.add('active');
|
|
|
+ } else {
|
|
|
+ tab.classList.remove('active');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ contents.forEach(content => {
|
|
|
+ if (content.dataset.content === 'list') {
|
|
|
+ content.classList.add('active');
|
|
|
+ } else {
|
|
|
+ content.classList.remove('active');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 更新插件下拉列表(用于分享和删除)
|
|
|
+ this.updatePluginSelects();
|
|
|
+
|
|
|
+ // 使用统一的刷新方法
|
|
|
+ this.refreshPluginList();
|
|
|
+
|
|
|
+ panel.classList.add('show');
|
|
|
+ }
|
|
|
+
|
|
|
+ hidePluginPanel() {
|
|
|
+ const panel = document.getElementById('pluginPanel');
|
|
|
+ if (panel) panel.classList.remove('show');
|
|
|
+ }
|
|
|
+
|
|
|
+ openPlugin(plugin) {
|
|
|
+ this.currentPlugin = plugin;
|
|
|
+ this.hidePluginPanel();
|
|
|
+
|
|
|
+ // Fullscreen mode — open the fullscreen panel
|
|
|
+ if (plugin.mode === 'fullscreen') {
|
|
|
+ this.openFullscreenPlugin(plugin);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Inline mode — open the standard dialog
|
|
|
+ const dialog = document.getElementById('pluginDialog');
|
|
|
+ const title = document.getElementById('pluginDialogTitle');
|
|
|
+ const desc = document.getElementById('pluginDialogDesc');
|
|
|
+ const inputs = document.getElementById('pluginInputs');
|
|
|
+
|
|
|
+ if (!dialog || !title || !desc || !inputs) return;
|
|
|
+
|
|
|
+ title.textContent = plugin.icon + ' ' + plugin.name;
|
|
|
+ desc.textContent = plugin.description;
|
|
|
+
|
|
|
+ inputs.innerHTML = '';
|
|
|
+ plugin.inputs.forEach(input => {
|
|
|
+ const inputGroup = document.createElement('div');
|
|
|
+ inputGroup.className = 'input-group';
|
|
|
+ inputGroup.innerHTML = `
|
|
|
+ <label>${input.label}</label>
|
|
|
+ <input type="text"
|
|
|
+ name="${input.name}"
|
|
|
+ value="${input.defaultValue || ''}"
|
|
|
+ placeholder="${input.placeholder || ''}">
|
|
|
+ `;
|
|
|
+ inputs.appendChild(inputGroup);
|
|
|
+ });
|
|
|
+
|
|
|
+ dialog.classList.add('show');
|
|
|
+ }
|
|
|
+
|
|
|
+ hidePluginDialog() {
|
|
|
+ const dialog = document.getElementById('pluginDialog');
|
|
|
+ if (dialog) dialog.classList.remove('show');
|
|
|
+ this.currentPlugin = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ openFullscreenPlugin(plugin) {
|
|
|
+ const panel = document.getElementById('fullscreenPanel');
|
|
|
+ document.getElementById('fsPluginTitle').textContent = plugin.icon + ' ' + plugin.name;
|
|
|
+ panel.classList.add('show');
|
|
|
+
|
|
|
+ // Run the plugin in fullscreen mode
|
|
|
+ const pluginOutputs = [];
|
|
|
+ const ijsAPI = this.createIJSAPI(pluginOutputs);
|
|
|
+ this._lastIjsApi = ijsAPI; // 保存引用供 MCP __tap 调试使用
|
|
|
+
|
|
|
+ // Create a default inputs object from plugin defaults
|
|
|
+ const inputValues = {};
|
|
|
+ if (plugin.inputs) {
|
|
|
+ plugin.inputs.forEach(function(inp) {
|
|
|
+ inputValues[inp.name] = inp.defaultValue || '';
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fullscreen plugins don't return values — they use IJS.canvas/IJS.input
|
|
|
+ const func = new Function('inputs', 'IJS', plugin.jsCode + '\ncalculate(inputs, IJS);');
|
|
|
+ try {
|
|
|
+ func(inputValues, ijsAPI);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Fullscreen plugin error:', e);
|
|
|
+ ijsAPI.ui.close();
|
|
|
+ this.addOutput('插件错误: ' + e.message, 'error');
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ executeCurrentPlugin() {
|
|
|
+ if (!this.currentPlugin) return;
|
|
|
+
|
|
|
+ const inputsDiv = document.getElementById('pluginInputs');
|
|
|
+ if (!inputsDiv) return;
|
|
|
+
|
|
|
+ const inputElements = inputsDiv.querySelectorAll('input');
|
|
|
+ const inputValues = {};
|
|
|
+
|
|
|
+ // 收集输入值并转换为 Symja 可运算类型
|
|
|
+ inputElements.forEach(input => {
|
|
|
+ inputValues[input.name] = this.convertInputToSymjaType(input.value);
|
|
|
+ });
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 用于存储插件的控制台输出
|
|
|
+ const pluginOutputs = [];
|
|
|
+
|
|
|
+ // 创建 IJS API 对象
|
|
|
+ const ijsAPI = this.createIJSAPI(pluginOutputs);
|
|
|
+
|
|
|
+ // 执行插件代码
|
|
|
+ const func = new Function('inputs', 'IJS', this.currentPlugin.jsCode + '\nreturn calculate(inputs, IJS);');
|
|
|
+ const result = func(inputValues, ijsAPI);
|
|
|
+
|
|
|
+ // 验证返回值是否为有效的可运算类型
|
|
|
+ if (typeof result !== 'string' || result.trim() === '') {
|
|
|
+ throw new Error('插件必须返回非空字符串类型的 Symja 表达式');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 验证返回值是否是有效的 Symja 表达式(尝试计算)
|
|
|
+ try {
|
|
|
+ Android.symjaEval(result);
|
|
|
+ } catch (e) {
|
|
|
+ throw new Error('插件返回值必须是有效的 Symja 表达式,当前返回: ' + result);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 显示插件名称
|
|
|
+ this.addOutput(`${this.currentPlugin.icon} ${this.currentPlugin.name}`, 'input');
|
|
|
+
|
|
|
+ // 显示插件的控制台输出(如果有)
|
|
|
+ if (pluginOutputs.length > 0) {
|
|
|
+ pluginOutputs.forEach(output => {
|
|
|
+ this.addOutput(output, 'plugin-info');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 显示插件的返回值(可运算结果)
|
|
|
+ this.addOutput(result, 'plugin');
|
|
|
+
|
|
|
+ // 将插件结果保存到 Symja 引擎,使其可以通过 % 引用
|
|
|
+ try {
|
|
|
+ Android.evaluate(`Out = ${result}`);
|
|
|
+ } catch (e) {
|
|
|
+ console.log('Plugin result storage error:', e);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.scrollToBottom();
|
|
|
+ this.hidePluginDialog();
|
|
|
+ } catch (e) {
|
|
|
+ this.addOutput(`插件执行错误: ${e.message}`, 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ scrollToBottom() {
|
|
|
+ this.output.scrollTop = this.output.scrollHeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ escapeHtml(text) {
|
|
|
+ const div = document.createElement('div');
|
|
|
+ div.textContent = text;
|
|
|
+ return div.innerHTML;
|
|
|
+ }
|
|
|
+
|
|
|
+ hideKeyboard() {
|
|
|
+ // 移除输入框焦点
|
|
|
+ if (this.input) {
|
|
|
+ this.input.blur();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用 Android 接口隐藏键盘
|
|
|
+ try {
|
|
|
+ if (typeof Android !== 'undefined' && Android.hideKeyboard) {
|
|
|
+ Android.hideKeyboard();
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.log('hideKeyboard not available:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ pollMcpEvents() {
|
|
|
+ try {
|
|
|
+ if (typeof Android === 'undefined' || !Android.getMcpEvents) return;
|
|
|
+ const json = Android.getMcpEvents();
|
|
|
+ if (!json || json === '[]') return;
|
|
|
+ const events = JSON.parse(json);
|
|
|
+ events.forEach(ev => {
|
|
|
+ // 调试命令:MCP 触发打开全屏插件
|
|
|
+ if (ev.expression.startsWith('##OPEN_PLUGIN##')) {
|
|
|
+ var pid = ev.expression.substring(16);
|
|
|
+ var pl = this.plugins.find(function(p) { return p.id === pid || p.command === pid; });
|
|
|
+ if (pl) this.openPlugin(pl);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // __eval 结果回传
|
|
|
+ if (ev.expression === '__eval_result') {
|
|
|
+ try {
|
|
|
+ var er = JSON.parse(ev.result);
|
|
|
+ if (er.ok) {
|
|
|
+ this.addOutput('__eval → ' + JSON.stringify(er.result), 'mcp-result');
|
|
|
+ } else {
|
|
|
+ this.addOutput('__eval error: ' + er.error, 'error');
|
|
|
+ }
|
|
|
+ } catch(e) {
|
|
|
+ this.addOutput('__eval raw: ' + ev.result, 'mcp-result');
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调试命令:模拟画布点击 __tap x y (逻辑坐标)
|
|
|
+ if (ev.expression.startsWith('__tap ')) {
|
|
|
+ var parts = ev.expression.split(/\s+/);
|
|
|
+ var tx = parseFloat(parts[1]), ty = parseFloat(parts[2]);
|
|
|
+ if (!isNaN(tx) && !isNaN(ty)) {
|
|
|
+ // 将逻辑坐标转换为屏幕 clientX/Y
|
|
|
+ var c = document.querySelector('#fullscreenContent canvas');
|
|
|
+ if (c) {
|
|
|
+ var r = c.getBoundingClientRect();
|
|
|
+ var cx = r.left + (tx / (parseInt(c.style.width) || r.width)) * r.width;
|
|
|
+ var cy = r.top + (ty / (parseInt(c.style.height) || r.height)) * r.height;
|
|
|
+ // 通过 _fireTouch 直接触发插件回调
|
|
|
+ try {
|
|
|
+ // 获取当前插件的 IJS API 实例,调用 _fireTouch
|
|
|
+ var apiInstance = symjaConsole._lastIjsApi;
|
|
|
+ if (apiInstance && apiInstance.input && apiInstance.input._fireTouch) {
|
|
|
+ apiInstance.input._fireTouch(cx, cy);
|
|
|
+ }
|
|
|
+ } catch(e) { console.log('_fireTouch error:', e); }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.mcpEventCount++;
|
|
|
+ this.addToHistory(ev.expression);
|
|
|
+ this.addOutput(ev.expression, 'mcp-input', { seq: this.mcpEventCount });
|
|
|
+ // 检测 LaTeX 渲染
|
|
|
+ if (ev.result && ev.result.startsWith('##TEXFORM##')) {
|
|
|
+ const latex = ev.result.substring(11);
|
|
|
+ this.addOutput(latex, 'mcp-latex', { renderLatex: true });
|
|
|
+ }
|
|
|
+ // 检测绘图结果 → Canvas 渲染
|
|
|
+ else if (ev.result && ev.result.startsWith('{"type":"plot"')) {
|
|
|
+ this.addPlotOutput(ev.result, this.mcpEventCount);
|
|
|
+ } else {
|
|
|
+ this.addOutput(ev.result, 'mcp-result', { seq: this.mcpEventCount });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ this.scrollToBottom();
|
|
|
+ } catch (e) {
|
|
|
+ // Ignore polling errors
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 初始化控制台
|
|
|
+let symjaConsole;
|
|
|
+document.addEventListener('DOMContentLoaded', () => {
|
|
|
+ symjaConsole = new SymjaConsole();
|
|
|
+});
|
|
|
+
|
|
|
+// ── Canvas 绘图渲染器 ────────────────────────────────────────
|
|
|
+class PlotRenderer {
|
|
|
+ constructor() {
|
|
|
+ this.canvas = null;
|
|
|
+ this.ctx = null;
|
|
|
+ this.data = null;
|
|
|
+ this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
+ this.bounds = { xMin: 0, xMax: 1, yMin: 0, yMax: 1 };
|
|
|
+ this.dpr = window.devicePixelRatio || 1;
|
|
|
+ this.colors = [
|
|
|
+ [0.369, 0.507, 0.710], [0.851, 0.208, 0.125],
|
|
|
+ [0.267, 0.612, 0.329], [0.737, 0.349, 0.800],
|
|
|
+ [1.0, 0.608, 0.0]
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ render(canvas, data) {
|
|
|
+ this.canvas = canvas;
|
|
|
+ this.ctx = canvas.getContext('2d');
|
|
|
+ this.data = data;
|
|
|
+
|
|
|
+ // 计算全局范围
|
|
|
+ this.bounds = this.computeBounds(data);
|
|
|
+ const padX = (this.bounds.xMax - this.bounds.xMin) * 0.05 || 0.5;
|
|
|
+ const padY = (this.bounds.yMax - this.bounds.yMin) * 0.05 || 0.5;
|
|
|
+ this.bounds.xMin -= padX;
|
|
|
+ this.bounds.xMax += padX;
|
|
|
+ this.bounds.yMin -= padY;
|
|
|
+ this.bounds.yMax += padY;
|
|
|
+
|
|
|
+ // 强制等比例:确保圆不变椭圆
|
|
|
+ const m = 60, cw = canvas.width - 2 * m, ch = canvas.height - 2 * m;
|
|
|
+ const xr = this.bounds.xMax - this.bounds.xMin;
|
|
|
+ const yr = this.bounds.yMax - this.bounds.yMin;
|
|
|
+ if (xr > 0 && yr > 0) {
|
|
|
+ if (xr / cw > yr / ch) {
|
|
|
+ const ny = xr * ch / cw;
|
|
|
+ const my = (this.bounds.yMin + this.bounds.yMax) / 2;
|
|
|
+ this.bounds.yMin = my - ny / 2;
|
|
|
+ this.bounds.yMax = my + ny / 2;
|
|
|
+ } else {
|
|
|
+ const nx = yr * cw / ch;
|
|
|
+ const mx = (this.bounds.xMin + this.bounds.xMax) / 2;
|
|
|
+ this.bounds.xMin = mx - nx / 2;
|
|
|
+ this.bounds.xMax = mx + nx / 2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
+ this.draw();
|
|
|
+
|
|
|
+ // 手势绑定
|
|
|
+ this.bindGestures();
|
|
|
+ }
|
|
|
+
|
|
|
+ computeBounds(data) {
|
|
|
+ let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
|
|
|
+ if (!data.elements) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
|
|
|
+ for (const el of data.elements) {
|
|
|
+ if (!el.points) continue;
|
|
|
+ for (const pt of el.points) {
|
|
|
+ xMin = Math.min(xMin, pt[0]);
|
|
|
+ xMax = Math.max(xMax, pt[0]);
|
|
|
+ yMin = Math.min(yMin, pt[1]);
|
|
|
+ yMax = Math.max(yMax, pt[1]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!isFinite(xMin)) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
|
|
|
+ return { xMin, xMax, yMin, yMax };
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 坐标变换 ──────────────────────────────────────
|
|
|
+ toCanvas(px, py) {
|
|
|
+ const w = this.canvas.width, h = this.canvas.height;
|
|
|
+ const m = 60; // 边距
|
|
|
+ const cw = w - 2 * m, ch = h - 2 * m;
|
|
|
+ const t = this.transform;
|
|
|
+ const b = this.bounds;
|
|
|
+ const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
|
|
|
+ return {
|
|
|
+ x: m + (px - b.xMin - t.ox * xRange) / (xRange * t.sx) * cw,
|
|
|
+ y: h - m - (py - b.yMin - t.oy * yRange) / (yRange * t.sy) * ch
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 主绘制 ────────────────────────────────────────
|
|
|
+ draw() {
|
|
|
+ const ctx = this.ctx;
|
|
|
+ const w = this.canvas.width, h = this.canvas.height;
|
|
|
+ const m = 60;
|
|
|
+ const cw = w - 2 * m, ch = h - 2 * m;
|
|
|
+ const t = this.transform;
|
|
|
+ const b = this.bounds;
|
|
|
+
|
|
|
+ // 背景
|
|
|
+ ctx.fillStyle = '#FFFFFF';
|
|
|
+ ctx.fillRect(0, 0, w, h);
|
|
|
+
|
|
|
+ // 网格
|
|
|
+ this.drawGrid(m, cw, ch);
|
|
|
+
|
|
|
+ // 坐标轴
|
|
|
+ this.drawAxes(m, cw, ch);
|
|
|
+
|
|
|
+ // 数据曲线
|
|
|
+ if (this.data.elements) {
|
|
|
+ const useColors = this.colors;
|
|
|
+ this.data.elements.forEach((el, idx) => {
|
|
|
+ if (!el.points || el.points.length === 0) return;
|
|
|
+ const color = el.color || useColors[idx % useColors.length];
|
|
|
+ const rgb = `rgb(${Math.round(color[0]*255)},${Math.round(color[1]*255)},${Math.round(color[2]*255)})`;
|
|
|
+
|
|
|
+ if (el.type === 'line' || (el.type === 'point' && this.data.options && this.data.options.joined)) {
|
|
|
+ ctx.strokeStyle = rgb;
|
|
|
+ ctx.lineWidth = 3 * this.dpr;
|
|
|
+ ctx.beginPath();
|
|
|
+ for (let i = 0; i < el.points.length; i++) {
|
|
|
+ const pt = this.toCanvas(el.points[i][0], el.points[i][1]);
|
|
|
+ if (i === 0) ctx.moveTo(pt.x, pt.y);
|
|
|
+ else ctx.lineTo(pt.x, pt.y);
|
|
|
+ }
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (el.type === 'point') {
|
|
|
+ ctx.fillStyle = rgb;
|
|
|
+ for (const pt of el.points) {
|
|
|
+ const p = this.toCanvas(pt[0], pt[1]);
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(p.x, p.y, 3 * this.dpr, 0, Math.PI * 2);
|
|
|
+ ctx.fill();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ drawGrid(m, cw, ch) {
|
|
|
+ const ctx = this.ctx;
|
|
|
+ const b = this.bounds;
|
|
|
+ const t = this.transform;
|
|
|
+ const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
|
|
|
+
|
|
|
+ ctx.strokeStyle = '#E8EDF2';
|
|
|
+ ctx.lineWidth = Math.round(1 * this.dpr);
|
|
|
+ ctx.fillStyle = '#8B95A5';
|
|
|
+ ctx.font = '11px -apple-system, sans-serif';
|
|
|
+ ctx.textAlign = 'center';
|
|
|
+
|
|
|
+ const xStep = this.niceStep(xRange, 5);
|
|
|
+ const yStep = this.niceStep(yRange, 5);
|
|
|
+
|
|
|
+ // X 网格 + 标签
|
|
|
+ let xStart = Math.floor((b.xMin + t.ox * xRange) / xStep) * xStep;
|
|
|
+ for (let x = xStart; x <= b.xMin + t.ox * xRange + xRange / t.sx + xStep; x += xStep) {
|
|
|
+ const pt = this.toCanvas(x, 0);
|
|
|
+ if (pt.x >= m && pt.x <= m + cw) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(pt.x, m);
|
|
|
+ ctx.lineTo(pt.x, m + ch);
|
|
|
+ ctx.stroke();
|
|
|
+ ctx.fillText(this.fmtNum(x), pt.x, m + ch + 16);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Y 网格 + 标签
|
|
|
+ ctx.textAlign = 'right';
|
|
|
+ let yStart = Math.floor((b.yMin + t.oy * yRange) / yStep) * yStep;
|
|
|
+ for (let y = yStart; y <= b.yMin + t.oy * yRange + yRange / t.sy + yStep; y += yStep) {
|
|
|
+ const pt = this.toCanvas(0, y);
|
|
|
+ if (pt.y >= m && pt.y <= m + ch) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(m, pt.y);
|
|
|
+ ctx.lineTo(m + cw, pt.y);
|
|
|
+ ctx.stroke();
|
|
|
+ ctx.fillText(this.fmtNum(y), m - 8, pt.y + 4);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ drawAxes(m, cw, ch) {
|
|
|
+ const ctx = this.ctx;
|
|
|
+ const b = this.bounds;
|
|
|
+ const t = this.transform;
|
|
|
+ const origin = this.toCanvas(0, 0);
|
|
|
+
|
|
|
+ ctx.strokeStyle = '#1976D2';
|
|
|
+ ctx.lineWidth = Math.round(2 * this.dpr);
|
|
|
+ ctx.fillStyle = '#1976D2';
|
|
|
+ ctx.font = 'bold 11px -apple-system, sans-serif';
|
|
|
+
|
|
|
+ // X 轴
|
|
|
+ if (origin.y >= m && origin.y <= m + ch) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(m, origin.y);
|
|
|
+ ctx.lineTo(m + cw, origin.y);
|
|
|
+ ctx.stroke();
|
|
|
+ // 箭头
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(m + cw, origin.y);
|
|
|
+ ctx.lineTo(m + cw - 8, origin.y - 4);
|
|
|
+ ctx.lineTo(m + cw - 8, origin.y + 4);
|
|
|
+ ctx.fill();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Y 轴
|
|
|
+ if (origin.x >= m && origin.x <= m + cw) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(origin.x, m + ch);
|
|
|
+ ctx.lineTo(origin.x, m);
|
|
|
+ ctx.stroke();
|
|
|
+ // 箭头
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(origin.x, m);
|
|
|
+ ctx.lineTo(origin.x - 4, m + 8);
|
|
|
+ ctx.lineTo(origin.x + 4, m + 8);
|
|
|
+ ctx.fill();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 原点标签
|
|
|
+ if (origin.x >= m && origin.x <= m + cw && origin.y >= m && origin.y <= m + ch) {
|
|
|
+ ctx.textAlign = 'right';
|
|
|
+ ctx.fillText('O', origin.x - 6, origin.y + 14);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ niceStep(range, target) {
|
|
|
+ const rough = range / target;
|
|
|
+ const exp = Math.pow(10, Math.floor(Math.log10(rough)));
|
|
|
+ const mant = rough / exp;
|
|
|
+ let nice = 1;
|
|
|
+ if (mant <= 1.5) nice = 1;
|
|
|
+ else if (mant <= 3.5) nice = 2;
|
|
|
+ else if (mant <= 7.5) nice = 5;
|
|
|
+ else nice = 10;
|
|
|
+ return nice * exp;
|
|
|
+ }
|
|
|
+
|
|
|
+ fmtNum(n) {
|
|
|
+ if (Math.abs(n) < 1e-10) return '0';
|
|
|
+ if (Math.abs(n) < 0.001 || Math.abs(n) >= 1e6) return n.toExponential(2);
|
|
|
+ if (Number.isInteger(n)) return n.toString();
|
|
|
+ const s = n.toFixed(4);
|
|
|
+ return s.replace(/0+$/, '').replace(/\.$/, '');
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 手势 ──────────────────────────────────────────
|
|
|
+ bindGestures() {
|
|
|
+ const canvas = this.canvas;
|
|
|
+ // 移除旧监听器防止泄露
|
|
|
+ if (this._touchHandlers) {
|
|
|
+ canvas.removeEventListener('touchstart', this._touchHandlers.start);
|
|
|
+ canvas.removeEventListener('touchmove', this._touchHandlers.move);
|
|
|
+ canvas.removeEventListener('touchend', this._touchHandlers.end);
|
|
|
+ }
|
|
|
+ let lastDist = 0, lastX = 0, lastY = 0, lastTap = 0, initialPinch = false;
|
|
|
+ const self = this;
|
|
|
+
|
|
|
+ const onStart = (e) => {
|
|
|
+ if (e.touches.length >= 2) {
|
|
|
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
|
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
|
+ lastDist = Math.sqrt(dx*dx + dy*dy);
|
|
|
+ initialPinch = true;
|
|
|
+ }
|
|
|
+ if (e.touches.length === 1 && !initialPinch) {
|
|
|
+ lastX = e.touches[0].clientX;
|
|
|
+ lastY = e.touches[0].clientY;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const onMove = (e) => {
|
|
|
+ if (e.touches.length >= 2 && initialPinch && lastDist > 0) {
|
|
|
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
|
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
|
+ const dist = Math.sqrt(dx*dx + dy*dy);
|
|
|
+ const scale = dist / lastDist;
|
|
|
+ if (scale > 0.3 && scale < 3) {
|
|
|
+ self.transform.sx = Math.max(0.1, Math.min(20, self.transform.sx / scale));
|
|
|
+ self.transform.sy = Math.max(0.1, Math.min(20, self.transform.sy / scale));
|
|
|
+ }
|
|
|
+ lastDist = dist;
|
|
|
+ self.draw();
|
|
|
+ e.preventDefault();
|
|
|
+ } else if (e.touches.length === 1 && !initialPinch) {
|
|
|
+ const dx = (e.touches[0].clientX - lastX) / canvas.clientWidth;
|
|
|
+ const dy = (e.touches[0].clientY - lastY) / canvas.clientHeight;
|
|
|
+ self.transform.ox -= dx / self.transform.sx;
|
|
|
+ self.transform.oy += dy / self.transform.sy;
|
|
|
+ lastX = e.touches[0].clientX;
|
|
|
+ lastY = e.touches[0].clientY;
|
|
|
+ self.draw();
|
|
|
+ e.preventDefault();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const onEnd = (e) => {
|
|
|
+ if (e.touches.length === 0) {
|
|
|
+ const now = Date.now();
|
|
|
+ if (initialPinch) {
|
|
|
+ initialPinch = false;
|
|
|
+ } else if (now - lastTap < 350) {
|
|
|
+ self.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
+ self.draw();
|
|
|
+ }
|
|
|
+ lastTap = now;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ canvas.addEventListener('touchstart', onStart, { passive: true });
|
|
|
+ canvas.addEventListener('touchmove', onMove, { passive: false });
|
|
|
+ canvas.addEventListener('touchend', onEnd, { passive: true });
|
|
|
+ this._touchHandlers = { start: onStart, move: onMove, end: onEnd };
|
|
|
+ }
|
|
|
+}
|