console.js 103 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516
  1. // 控制台交互逻辑
  2. class SymjaConsole {
  3. constructor() {
  4. this.output = document.getElementById('output');
  5. this.input = document.getElementById('input');
  6. this.inputPrompt = document.getElementById('inputPrompt');
  7. this.completionList = document.getElementById('completionList');
  8. this.helpBtn = document.getElementById('helpBtn');
  9. this.pluginsBtn = document.getElementById('pluginsBtn');
  10. this.settingsBtn = document.getElementById('settingsBtn');
  11. // 历史记录
  12. this.history = this.loadHistory();
  13. this.historyIndex = this.history.length;
  14. this.tempInput = '';
  15. // 插件相关
  16. this.plugins = [];
  17. this.currentPlugin = null;
  18. // 自动补全相关
  19. this.completions = [];
  20. this.completionIndex = -1;
  21. // 变量追踪
  22. this.variables = new Set();
  23. // 执行计数器
  24. this.executionCount = this.loadExecutionCount();
  25. this.init();
  26. this.loadPlugins();
  27. this.refreshPluginList(); // 初始化时刷新插件列表
  28. this.showWelcome();
  29. this.updatePrompt();
  30. }
  31. init() {
  32. // 输入框事件
  33. this.input.addEventListener('keydown', (e) => this.handleKeyDown(e));
  34. this.input.addEventListener('input', () => this.handleInput());
  35. // 发送按钮
  36. const sendBtn = document.getElementById('sendBtn');
  37. if (sendBtn) {
  38. sendBtn.addEventListener('click', () => this.evaluate());
  39. }
  40. // 顶部导航按钮
  41. this.helpBtn?.addEventListener('click', () => this.showHelp());
  42. this.pluginsBtn?.addEventListener('click', () => this.showPluginPanel());
  43. this.settingsBtn?.addEventListener('click', () => this.showSettings());
  44. // 设置面板
  45. document.getElementById('closeSettings')?.addEventListener('click', () => this.hideSettings());
  46. document.getElementById('applySettings')?.addEventListener('click', () => this.applySettings());
  47. // 加载已保存的设置
  48. this.loadSettings();
  49. // MCP 事件轮询(仅在服务器开启时运行)
  50. this.mcpEventCount = 0;
  51. this.mcpPollTimer = null;
  52. this.startMcpPollingIfNeeded();
  53. // 快捷键工具栏
  54. this.initShortcutsBar();
  55. // 帮助页面关闭按钮
  56. document.getElementById('closeHelp')?.addEventListener('click', () => {
  57. this.hideHelp();
  58. });
  59. // 恢复默认插件
  60. document.getElementById('restorePluginsBtn')?.addEventListener('click', () => {
  61. try {
  62. const result = Android.restoreDefaultPlugins();
  63. this.loadPlugins();
  64. this.refreshPluginList();
  65. this.addOutput(result, 'help');
  66. this.scrollToBottom();
  67. } catch(e) {
  68. this.addOutput('❌ 恢复失败: ' + e.message, 'error');
  69. }
  70. });
  71. // 全屏面板关闭
  72. document.getElementById('closeFullscreen')?.addEventListener('click', () => {
  73. const fs = document.getElementById('fullscreenPanel');
  74. fs.classList.remove('show');
  75. // 停止动画循环
  76. const api = this.createIJSAPI([]);
  77. if (api) api.canvas.stop();
  78. });
  79. // 插件面板事件
  80. document.getElementById('closePluginPanel')?.addEventListener('click', () => {
  81. this.hidePluginPanel();
  82. });
  83. document.getElementById('closePluginDialog')?.addEventListener('click', () => {
  84. this.hidePluginDialog();
  85. });
  86. document.getElementById('cancelPlugin')?.addEventListener('click', () => {
  87. this.hidePluginDialog();
  88. });
  89. document.getElementById('executePlugin')?.addEventListener('click', () => {
  90. this.executeCurrentPlugin();
  91. });
  92. }
  93. initShortcutsBar() {
  94. const buttons = document.querySelectorAll('.shortcut-btn');
  95. buttons.forEach(btn => {
  96. btn.addEventListener('click', (e) => {
  97. e.preventDefault();
  98. const action = btn.dataset.action;
  99. const value = btn.dataset.value;
  100. switch(action) {
  101. case 'bracket':
  102. case 'insert':
  103. this.insertText(value);
  104. break;
  105. case 'history-up':
  106. this.navigateHistory(-1);
  107. break;
  108. case 'history-down':
  109. this.navigateHistory(1);
  110. break;
  111. }
  112. // 保持输入框焦点
  113. this.input.focus();
  114. });
  115. });
  116. // 初始化插件标签页
  117. this.initPluginTabs();
  118. // 初始化插件分享功能
  119. this.initPluginShare();
  120. }
  121. initPluginTabs() {
  122. const tabs = document.querySelectorAll('.plugin-tab');
  123. const contents = document.querySelectorAll('.tab-content');
  124. tabs.forEach(tab => {
  125. tab.addEventListener('click', () => {
  126. const targetTab = tab.dataset.tab;
  127. // 切换标签激活状态
  128. tabs.forEach(t => t.classList.remove('active'));
  129. tab.classList.add('active');
  130. // 切换内容显示
  131. contents.forEach(content => {
  132. if (content.dataset.content === targetTab) {
  133. content.classList.add('active');
  134. } else {
  135. content.classList.remove('active');
  136. }
  137. });
  138. // 如果切换到导入/分享标签,更新插件列表
  139. if (targetTab === 'import') {
  140. this.updatePluginSelects();
  141. }
  142. });
  143. });
  144. }
  145. initPluginShare() {
  146. // 使用事件委托,在document上监听点击事件
  147. document.addEventListener('click', (e) => {
  148. const target = e.target;
  149. // 生成分享码
  150. if (target.id === 'generateShareCode') {
  151. console.log('generateShareCode clicked');
  152. e.preventDefault();
  153. this.generateShareCode();
  154. }
  155. // 复制分享码
  156. if (target.id === 'copyShareCode') {
  157. console.log('copyShareCode clicked');
  158. e.preventDefault();
  159. this.copyShareCode();
  160. }
  161. // 导入插件
  162. if (target.id === 'importPlugin') {
  163. console.log('importPlugin clicked');
  164. e.preventDefault();
  165. this.importPlugin();
  166. }
  167. // 删除插件
  168. if (target.id === 'deletePlugin') {
  169. console.log('deletePlugin clicked');
  170. e.preventDefault();
  171. this.deletePluginConfirm();
  172. }
  173. });
  174. }
  175. updatePluginSelects() {
  176. const shareSelect = document.getElementById('sharePluginSelect');
  177. const deleteSelect = document.getElementById('deletePluginSelect');
  178. if (shareSelect) {
  179. shareSelect.innerHTML = '<option value="">选择插件...</option>';
  180. this.plugins.forEach((plugin, index) => {
  181. const option = document.createElement('option');
  182. option.value = index;
  183. option.textContent = `${plugin.icon} ${plugin.name}`;
  184. shareSelect.appendChild(option);
  185. });
  186. }
  187. if (deleteSelect) {
  188. deleteSelect.innerHTML = '<option value="">选择要删除的插件...</option>';
  189. this.plugins.forEach((plugin, index) => {
  190. const option = document.createElement('option');
  191. option.value = index;
  192. option.textContent = `${plugin.icon} ${plugin.name}`;
  193. deleteSelect.appendChild(option);
  194. });
  195. }
  196. }
  197. generateShareCode() {
  198. const select = document.getElementById('sharePluginSelect');
  199. const output = document.getElementById('shareCodeOutput');
  200. const copyBtn = document.getElementById('copyShareCode');
  201. if (!select || !output) return;
  202. const index = parseInt(select.value);
  203. if (isNaN(index) || index < 0 || index >= this.plugins.length) {
  204. this.addOutput('❌ 请先选择一个插件', 'error');
  205. this.scrollToBottom();
  206. return;
  207. }
  208. const plugin = this.plugins[index];
  209. const shareCode = JSON.stringify(plugin, null, 2);
  210. output.value = shareCode;
  211. if (copyBtn) copyBtn.style.display = 'block';
  212. this.addOutput(`✅ 已生成 "${plugin.name}" 的分享码`, 'help');
  213. this.scrollToBottom();
  214. }
  215. copyShareCode() {
  216. const output = document.getElementById('shareCodeOutput');
  217. if (!output || !output.value) return;
  218. output.select();
  219. document.execCommand('copy');
  220. this.addOutput('📋 分享码已复制到剪贴板', 'help');
  221. this.scrollToBottom();
  222. }
  223. importPlugin() {
  224. const input = document.getElementById('importCodeInput');
  225. if (!input || !input.value.trim()) {
  226. this.addOutput('❌ 请粘贴插件代码', 'error');
  227. this.scrollToBottom();
  228. return;
  229. }
  230. try {
  231. const pluginData = JSON.parse(input.value);
  232. // 验证插件格式
  233. if (!pluginData.id || !pluginData.name || !pluginData.jsCode) {
  234. throw new Error('插件格式不正确,缺少必要字段');
  235. }
  236. // 调用 Android 接口保存插件
  237. try {
  238. const result = Android.savePlugin(JSON.stringify(pluginData));
  239. // 重新加载插件
  240. this.loadPlugins();
  241. this.updatePluginSelects();
  242. // 刷新插件列表显示
  243. this.refreshPluginList();
  244. // 显示成功消息
  245. this.addOutput(`✅ ${result}`, 'help');
  246. this.addOutput(`📦 插件 "${pluginData.name}" 已添加到列表`, 'help');
  247. // 清空输入
  248. input.value = '';
  249. } catch (e) {
  250. this.addOutput(`❌ 保存失败: ${e.message}`, 'error');
  251. }
  252. this.scrollToBottom();
  253. } catch (e) {
  254. this.addOutput(`❌ 解析失败: ${e.message}`, 'error');
  255. this.scrollToBottom();
  256. }
  257. }
  258. deletePluginConfirm() {
  259. const select = document.getElementById('deletePluginSelect');
  260. if (!select) {
  261. console.error('deletePluginSelect element not found');
  262. return;
  263. }
  264. console.log('Select value:', select.value);
  265. console.log('Plugins length:', this.plugins.length);
  266. const index = parseInt(select.value);
  267. if (isNaN(index) || index < 0 || index >= this.plugins.length) {
  268. this.addOutput('❌ 请先选择要删除的插件', 'error');
  269. this.scrollToBottom();
  270. return;
  271. }
  272. const plugin = this.plugins[index];
  273. console.log('Deleting plugin:', plugin.name, 'ID:', plugin.id);
  274. // 使用自定义确认对话框或直接删除
  275. const confirmMessage = `确定要删除插件 "${plugin.name}" 吗?`;
  276. console.log('Showing confirm dialog:', confirmMessage);
  277. let userConfirmed = false;
  278. try {
  279. userConfirmed = confirm(confirmMessage);
  280. console.log('User confirmed:', userConfirmed);
  281. } catch (e) {
  282. console.error('Confirm dialog error:', e);
  283. // 如果confirm失败,直接删除
  284. userConfirmed = true;
  285. }
  286. if (userConfirmed) {
  287. console.log('Proceeding with deletion...');
  288. try {
  289. const result = Android.deletePlugin(plugin.id);
  290. console.log('Delete result:', result);
  291. this.addOutput(`✅ ${result}`, 'help');
  292. // 重新加载插件
  293. this.loadPlugins();
  294. this.updatePluginSelects();
  295. // 刷新插件列表显示
  296. this.refreshPluginList();
  297. } catch (e) {
  298. console.error('Delete error:', e);
  299. this.addOutput(`❌ 删除失败: ${e.message}`, 'error');
  300. }
  301. this.scrollToBottom();
  302. } else {
  303. console.log('User cancelled deletion');
  304. }
  305. }
  306. refreshPluginList() {
  307. const list = document.getElementById('pluginList');
  308. console.log('refreshPluginList called, list element:', list);
  309. console.log('Current plugins count:', this.plugins.length);
  310. if (!list) return;
  311. list.innerHTML = '';
  312. if (this.plugins.length === 0) {
  313. console.log('No plugins, showing empty message');
  314. list.innerHTML = '<div class="no-plugins">暂无插件</div>';
  315. } else {
  316. console.log('Rendering', this.plugins.length, 'plugins');
  317. this.plugins.forEach(plugin => {
  318. const item = document.createElement('div');
  319. item.className = 'plugin-item';
  320. const commandInfo = plugin.command ? `<div class="plugin-command">命令: ${plugin.command}</div>` : '';
  321. item.innerHTML = `
  322. <div class="plugin-icon">${plugin.icon}</div>
  323. <div class="plugin-info">
  324. <div class="plugin-name">${plugin.name}</div>
  325. <div class="plugin-desc">${plugin.description}</div>
  326. ${commandInfo}
  327. </div>
  328. `;
  329. item.addEventListener('click', () => this.openPlugin(plugin));
  330. list.appendChild(item);
  331. });
  332. console.log('Finished rendering plugins, list children:', list.children.length);
  333. }
  334. }
  335. insertText(text) {
  336. const start = this.input.selectionStart;
  337. const end = this.input.selectionEnd;
  338. const value = this.input.value;
  339. // 插入文本
  340. this.input.value = value.substring(0, start) + text + value.substring(end);
  341. // 设置光标位置
  342. const newPos = start + text.length;
  343. this.input.setSelectionRange(newPos, newPos);
  344. // 触发输入事件
  345. this.handleInput();
  346. }
  347. handleKeyDown(e) {
  348. if (e.key === 'Enter') {
  349. e.preventDefault();
  350. this.hideCompletionList();
  351. this.evaluate();
  352. } else if (e.key === 'ArrowUp') {
  353. e.preventDefault();
  354. this.hideCompletionList();
  355. this.navigateHistory(-1);
  356. } else if (e.key === 'ArrowDown') {
  357. e.preventDefault();
  358. this.hideCompletionList();
  359. this.navigateHistory(1);
  360. } else if (e.key === 'Escape') {
  361. this.input.value = '';
  362. this.completions = [];
  363. this.completionIndex = -1;
  364. this.hideCompletionList();
  365. }
  366. }
  367. handleInput() {
  368. // 更新自动补全列表
  369. console.log('handleInput called, value:', this.input.value);
  370. this.updateCompletionList();
  371. }
  372. showWelcome() {
  373. const line = document.createElement('div');
  374. line.className = 'terminal-line';
  375. line.innerHTML = `<div class="welcome-message">
  376. <h2>🧮 某人的计算器 v1.2</h2>
  377. <p>基于 Symja 的交互式符号计算控制台</p>
  378. <p><strong>快速开始:</strong></p>
  379. <p><code>2+2</code> 算术运算</p>
  380. <p><code>D[x^3, x]</code> 求导</p>
  381. <p><code>Integrate[x^2, {x,0,1}]</code> 定积分</p>
  382. <p><code>Solve[x^2-5*x+6==0, x]</code> 解方程</p>
  383. <p><code>Plot[Sin[x], {x,0,2*Pi}]</code> 函数绘图</p>
  384. <p style="margin-top:12px">输入 <code>help</code> 查看完整文档 · 输入 <code>plugins</code> 管理插件</p>
  385. </div>`;
  386. this.output.appendChild(line);
  387. }
  388. evaluate() {
  389. let expression = this.input.value.trim();
  390. if (!expression) {
  391. return;
  392. }
  393. // 处理特殊命令
  394. if (this.handleSpecialCommand(expression)) {
  395. this.addToHistory(expression);
  396. this.input.value = '';
  397. return;
  398. }
  399. // 添加到历史记录
  400. this.addToHistory(expression);
  401. // 显示输入(带计数器)
  402. this.addOutput(expression, 'input', { count: this.executionCount });
  403. // 调用Android接口进行计算
  404. try {
  405. const result = Android.evaluatePlot(expression);
  406. // 检测是否是 Graphics/绘图结果
  407. if (typeof result === 'string' && result.startsWith('{"type":"plot"')) {
  408. this.addPlotOutput(result, this.executionCount);
  409. }
  410. // LaTeX 渲染
  411. else if (result.startsWith('##TEXFORM##')) {
  412. const latex = result.substring(11);
  413. this.addOutput(latex, 'result', { renderLatex: true, count: this.executionCount });
  414. this.scrollToBottom();
  415. }
  416. // 普通文本结果
  417. else {
  418. this.addOutput(result, 'result', { count: this.executionCount });
  419. }
  420. // 追踪变量
  421. this.trackVariables(expression);
  422. } catch (e) {
  423. this.addOutput('错误: 无法连接到计算引擎', 'error');
  424. }
  425. // 增加计数器
  426. this.executionCount++;
  427. this.saveExecutionCount();
  428. this.updatePrompt();
  429. // 清空输入框
  430. this.input.value = '';
  431. this.completions = [];
  432. this.completionIndex = -1;
  433. // 滚动到底部
  434. this.scrollToBottom();
  435. }
  436. addPlotOutput(plotJson, count, labels) {
  437. const data = JSON.parse(plotJson);
  438. const line = document.createElement('div');
  439. line.className = 'terminal-line';
  440. const container = document.createElement('div');
  441. container.className = 'plot-container';
  442. const canvas = document.createElement('canvas');
  443. canvas.className = 'plot-canvas';
  444. const dpr = window.devicePixelRatio || 1;
  445. canvas.width = 500 * dpr;
  446. canvas.height = 500 * dpr;
  447. canvas.style.width = '100%';
  448. canvas.style.height = '100%';
  449. container.appendChild(canvas);
  450. // 长按保存(单指静止800ms触发)
  451. let longPressTimer;
  452. canvas.addEventListener('touchstart', (e) => {
  453. clearTimeout(longPressTimer);
  454. if (e.touches.length === 1) {
  455. longPressTimer = setTimeout(() => this.savePlot(canvas), 800);
  456. }
  457. });
  458. canvas.addEventListener('touchend', () => clearTimeout(longPressTimer));
  459. canvas.addEventListener('touchmove', () => clearTimeout(longPressTimer));
  460. line.appendChild(container);
  461. this.output.appendChild(line);
  462. if (!this.plotRenderer) {
  463. this.plotRenderer = new PlotRenderer();
  464. }
  465. this.plotRenderer.render(canvas, data);
  466. }
  467. buildLegend(data, labels) {
  468. const div = document.createElement('div');
  469. div.className = 'plot-legend';
  470. const colors = [
  471. [0.369,0.507,0.710],[0.851,0.208,0.125],[0.267,0.612,0.329],
  472. [0.737,0.349,0.800],[1.0,0.608,0.0]
  473. ];
  474. data.elements.forEach((el, i) => {
  475. const c = el.color || colors[i % colors.length];
  476. const rgb = `rgb(${Math.round(c[0]*255)},${Math.round(c[1]*255)},${Math.round(c[2]*255)})`;
  477. const item = document.createElement('span');
  478. item.className = 'legend-item';
  479. item.innerHTML = `<span class="legend-swatch" style="background:${rgb}"></span>${labels ? labels[i] : '曲线'+(i+1)}`;
  480. div.appendChild(item);
  481. });
  482. return div;
  483. }
  484. savePlot(canvas) {
  485. try {
  486. const dataUrl = canvas.toDataURL('image/png');
  487. const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
  488. const filename = 'IJS_Plot_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
  489. const result = Android.savePlotImage(base64, filename);
  490. this.addOutput(result, 'help');
  491. this.scrollToBottom();
  492. } catch (e) {
  493. this.addOutput('保存失败: ' + e.message, 'error');
  494. this.scrollToBottom();
  495. }
  496. }
  497. handleSpecialCommand(cmd) {
  498. const lower = cmd.toLowerCase();
  499. if (lower === 'help' || lower === '?') {
  500. this.showHelp();
  501. return true;
  502. }
  503. if (lower === 'clear' || lower === 'cls') {
  504. this.clear();
  505. return true;
  506. }
  507. if (lower === 'history') {
  508. this.showHistory();
  509. return true;
  510. }
  511. if (lower === 'plugins') {
  512. this.showPluginPanel();
  513. return true;
  514. }
  515. if (lower === 'mcp') {
  516. this.showMcpAddress();
  517. return true;
  518. }
  519. // 调试:通过MCP evaluate("__open plugin_id")打开全屏插件
  520. if (lower.startsWith('__open ')) {
  521. var pid = lower.substring(7).trim();
  522. var pl = this.plugins.find(function(p) { return p.id === pid || p.command === pid; });
  523. if (pl) this.openPlugin(pl);
  524. else this.addOutput('插件未找到: ' + pid, 'error');
  525. return true;
  526. }
  527. if (lower === 'feedback') {
  528. this.showFeedback();
  529. return true;
  530. }
  531. if (lower.startsWith('clear ')) {
  532. const varName = cmd.substring(6).trim();
  533. this.clearVariable(varName);
  534. return true;
  535. }
  536. // 检查是否是插件命令
  537. if (this.handlePluginCommand(lower)) {
  538. return true;
  539. }
  540. return false;
  541. }
  542. handlePluginCommand(cmd) {
  543. // 检查是否是带参数的插件命令格式: command[arg1, arg2, ...]
  544. const paramMatch = cmd.match(/^([a-z0-9_-]+)\[(.*)\]$/i);
  545. if (paramMatch) {
  546. const commandName = paramMatch[1].toLowerCase();
  547. const argsString = paramMatch[2];
  548. // 查找匹配的插件
  549. const plugin = this.plugins.find(p => p.command && p.command.toLowerCase() === commandName);
  550. if (plugin) {
  551. // 解析参数
  552. const args = this.parsePluginArgs(argsString);
  553. // 验证参数数量
  554. if (args.length !== plugin.inputs.length) {
  555. this.addOutput(`❌ 参数数量不匹配。${plugin.name} 需要 ${plugin.inputs.length} 个参数,但提供了 ${args.length} 个`, 'error');
  556. this.scrollToBottom();
  557. return true;
  558. }
  559. // 执行插件(直接模式)
  560. this.executePluginDirect(plugin, args);
  561. return true;
  562. }
  563. }
  564. // 查找匹配的插件命令(无参数,打开对话框)
  565. for (const plugin of this.plugins) {
  566. if (plugin.command && plugin.command.toLowerCase() === cmd) {
  567. this.addOutput(`🔌 打开插件: ${plugin.name}`, 'help');
  568. this.scrollToBottom();
  569. this.openPlugin(plugin);
  570. return true;
  571. }
  572. }
  573. return false;
  574. }
  575. parsePluginArgs(argsString) {
  576. // 解析逗号分隔的参数,支持引号、大括号、方括号内的逗号
  577. const args = [];
  578. let current = '';
  579. let inQuotes = false;
  580. let quoteChar = '';
  581. let bracketDepth = 0; // 跟踪 {} 的嵌套深度
  582. let squareBracketDepth = 0; // 跟踪 [] 的嵌套深度
  583. for (let i = 0; i < argsString.length; i++) {
  584. const char = argsString[i];
  585. // 处理引号
  586. if ((char === '"' || char === "'") && (i === 0 || argsString[i-1] !== '\\')) {
  587. if (!inQuotes) {
  588. inQuotes = true;
  589. quoteChar = char;
  590. } else if (char === quoteChar) {
  591. inQuotes = false;
  592. quoteChar = '';
  593. }
  594. current += char;
  595. }
  596. // 处理大括号
  597. else if (char === '{' && !inQuotes) {
  598. bracketDepth++;
  599. current += char;
  600. }
  601. else if (char === '}' && !inQuotes) {
  602. bracketDepth--;
  603. current += char;
  604. }
  605. // 处理方括号
  606. else if (char === '[' && !inQuotes) {
  607. squareBracketDepth++;
  608. current += char;
  609. }
  610. else if (char === ']' && !inQuotes) {
  611. squareBracketDepth--;
  612. current += char;
  613. }
  614. // 处理逗号分隔符
  615. else if (char === ',' && !inQuotes && bracketDepth === 0 && squareBracketDepth === 0) {
  616. args.push(current.trim().replace(/^["']|["']$/g, ''));
  617. current = '';
  618. }
  619. else {
  620. current += char;
  621. }
  622. }
  623. if (current.trim()) {
  624. args.push(current.trim().replace(/^["']|["']$/g, ''));
  625. }
  626. return args;
  627. }
  628. executePluginDirect(plugin, args) {
  629. try {
  630. console.log('executePluginDirect called with args:', args);
  631. // 构建输入对象,并转换为 Symja 可运算类型
  632. const inputValues = {};
  633. plugin.inputs.forEach((input, index) => {
  634. const originalArg = args[index];
  635. const convertedArg = this.convertInputToSymjaType(originalArg);
  636. console.log(`Converting arg[${index}]: "${originalArg}" -> "${convertedArg}"`);
  637. inputValues[input.name] = convertedArg;
  638. });
  639. console.log('Final inputValues:', inputValues);
  640. // 用于存储插件的控制台输出
  641. const pluginOutputs = [];
  642. const ijsAPI = this.createIJSAPI(pluginOutputs);
  643. const func = new Function('inputs', 'IJS', plugin.jsCode + '\nreturn calculate(inputs, IJS);');
  644. const result = func(inputValues, ijsAPI);
  645. console.log('Plugin result:', result);
  646. // 验证返回值
  647. if (typeof result !== 'string' || result.trim() === '') {
  648. throw new Error('插件必须返回非空字符串类型的 Symja 表达式');
  649. }
  650. // 验证返回值是否是有效的 Symja 表达式
  651. try {
  652. Android.symjaEval(result);
  653. } catch (e) {
  654. throw new Error('插件返回值必须是有效的 Symja 表达式,当前返回: ' + result);
  655. }
  656. // 显示插件名称和参数
  657. const argsDisplay = args.map((arg, i) => `${plugin.inputs[i].name}=${arg}`).join(', ');
  658. this.addOutput(`${plugin.icon} ${plugin.name}[${argsDisplay}]`, 'input', { count: this.executionCount });
  659. // 显示插件的控制台输出(如果有)
  660. if (pluginOutputs.length > 0) {
  661. pluginOutputs.forEach(output => {
  662. this.addOutput(output, 'plugin-info');
  663. });
  664. }
  665. // 显示插件的返回值(可运算结果)
  666. this.addOutput(result, 'result', { count: this.executionCount });
  667. // 将插件结果保存到 Symja 引擎,使其可以通过 % 引用
  668. try {
  669. Android.evaluate(`Out = ${result}`);
  670. } catch (e) {
  671. console.log('Plugin result storage error:', e);
  672. }
  673. // 增加计数器
  674. this.executionCount++;
  675. this.saveExecutionCount();
  676. this.updatePrompt();
  677. this.scrollToBottom();
  678. } catch (e) {
  679. console.log('Plugin execution error:', e);
  680. this.addOutput(`插件执行错误: ${e.message}`, 'error');
  681. this.scrollToBottom();
  682. }
  683. }
  684. // 将用户输入转换为 Symja 可运算的类型
  685. convertInputToSymjaType(value) {
  686. console.log(`convertInputToSymjaType called with: "${value}"`);
  687. if (!value || typeof value !== 'string') {
  688. console.log('Value is not a string, returning as-is');
  689. return value;
  690. }
  691. const trimmed = value.trim();
  692. // 空值直接返回
  693. if (trimmed === '') {
  694. console.log('Value is empty, returning as-is');
  695. return trimmed;
  696. }
  697. // 检查是否是单个变量名(字母开头,后跟字母数字下划线)或 % 符号
  698. const varNameMatch = trimmed.match(/^([a-zA-Z]\w*|%)$/);
  699. console.log(`Variable name match result:`, varNameMatch);
  700. if (varNameMatch) {
  701. // 直接尝试求值,如果是已定义的变量会返回值,否则返回变量名本身
  702. try {
  703. const evalResult = Android.symjaEval(trimmed);
  704. console.log(`Evaluated "${trimmed}" to: "${evalResult}"`);
  705. // 如果求值结果与输入不同,说明变量已定义
  706. if (evalResult !== trimmed) {
  707. console.log(`Variable ${trimmed} is defined, using value: ${evalResult}`);
  708. return evalResult;
  709. } else {
  710. console.log(`Variable ${trimmed} is not defined or equals itself`);
  711. }
  712. } catch (e) {
  713. console.error(`Failed to evaluate ${trimmed}:`, e);
  714. }
  715. }
  716. // 尝试通过 Symja 验证,如果能成功解析说明是有效的表达式
  717. console.log(`Trying to validate as Symja expression: "${trimmed}"`);
  718. try {
  719. Android.symjaEval(trimmed);
  720. console.log(`Valid Symja expression, returning: "${trimmed}"`);
  721. return trimmed;
  722. } catch (e) {
  723. // 如果无法解析为 Symja 表达式,返回原值
  724. // 插件内部可以根据需要进一步处理
  725. console.log(`Not a valid Symja expression, returning as literal: "${trimmed}"`);
  726. return trimmed;
  727. }
  728. }
  729. createIJSAPI(pluginOutputs) {
  730. const self = this;
  731. // 闭包引用 — IJS.input.xy() 需要访问 canvas 对象
  732. const canvasRef = { _canvas: null, _logicalW: 600, _logicalH: 800 };
  733. return {
  734. // ── IJS.Symja 数学引擎 ─────────────────────────
  735. Symja: {
  736. print: function(message) {
  737. pluginOutputs.push(String(message));
  738. },
  739. eval: function(expression) {
  740. try { return Android.symjaEval(expression); }
  741. catch (e) { return '错误: 无法调用 Symja'; }
  742. },
  743. differentiate: function(expr, variable) { return this.eval(`D[${expr}, ${variable}]`); },
  744. integrate: function(expr, variable) { return this.eval(`Integrate[${expr}, ${variable}]`); },
  745. limit: function(expr, variable, point, direction) {
  746. try { return Android.limit(expr, variable, point, direction || ''); } catch (e) { return '错误:' + e.message; }
  747. },
  748. series: function(expr, variable, point, order) {
  749. try { return Android.series(expr, variable, point, order || 5); } catch (e) { return '错误:' + e.message; }
  750. },
  751. solve: function(equation, variable) { return this.eval(`Solve[${equation}, ${variable}]`); },
  752. simplify: function(expr) { return this.eval(`Simplify[${expr}]`); },
  753. expand: function(expr) { return this.eval(`Expand[${expr}]`); },
  754. factor: function(expr) { return this.eval(`Factor[${expr}]`); },
  755. apart: function(expr, variable) { try { return Android.apart(expr, variable); } catch (e) { return '错误:' + e.message; } },
  756. together: function(expr) { try { return Android.together(expr); } catch (e) { return '错误:' + e.message; } },
  757. trigSimplify: function(expr) { try { return Android.trigSimplify(expr); } catch (e) { return '错误:' + e.message; } },
  758. sum: function(expr, variable, start, end) {
  759. try { return Android.sum(expr, variable, String(start), String(end)); } catch (e) { return '错误:' + e.message; }
  760. },
  761. product: function(expr, variable, start, end) {
  762. try { return Android.product(expr, variable, String(start), String(end)); } catch (e) { return '错误:' + e.message; }
  763. },
  764. matrix: {
  765. det: function(matrix) { try { return Android.matrixOp('Det', matrix); } catch (e) { return '错误:' + e.message; } },
  766. inverse: function(matrix) { try { return Android.matrixOp('Inverse', matrix); } catch (e) { return '错误:' + e.message; } },
  767. transpose: function(matrix) { try { return Android.matrixOp('Transpose', matrix); } catch (e) { return '错误:' + e.message; } },
  768. eigenvalues: function(matrix) { try { return Android.matrixOp('Eigenvalues', matrix); } catch (e) { return '错误:' + e.message; } },
  769. rank: function(matrix) { try { return Android.matrixOp('MatrixRank', matrix); } catch (e) { return '错误:' + e.message; } }
  770. },
  771. gcd: function(a, b) { try { return Android.gcd(String(a), String(b)); } catch (e) { return '错误:' + e.message; } },
  772. lcm: function(a, b) { try { return Android.lcm(String(a), String(b)); } catch (e) { return '错误:' + e.message; } },
  773. factorInteger: function(n) { try { return Android.factorInteger(String(n)); } catch (e) { return '错误:' + e.message; } },
  774. isPrime: function(n) { try { return Android.isPrime(String(n)); } catch (e) { return '错误:' + e.message; } },
  775. fibonacci: function(n) { try { return Android.fibonacci(n); } catch (e) { return '错误:' + e.message; } },
  776. binomial: function(n, k) { try { return Android.binomial(String(n), String(k)); } catch (e) { return '错误:' + e.message; } },
  777. numeric: function(expr, precision) {
  778. try { return Android.numericEval(expr, precision || 16); } catch (e) { return '错误:' + e.message; }
  779. },
  780. plot: function(func, variable, xMin, xMax) {
  781. try { return Android.evaluatePlot('Plot[' + func + ', {' + variable + ', ' + xMin + ', ' + xMax + '}]'); } catch (e) { return 'Error:' + e.message; }
  782. },
  783. parametricPlot: function(xf, yf, t, tMin, tMax) {
  784. try { return Android.evaluatePlot('ParametricPlot[{' + xf + ', ' + yf + '}, {' + t + ', ' + tMin + ', ' + tMax + '}]'); } catch (e) { return 'Error:' + e.message; }
  785. },
  786. listPlot: function(points) {
  787. try { return Android.evaluatePlot('ListPlot[' + points + ']'); } catch (e) { return 'Error:' + e.message; }
  788. },
  789. toTeX: function(expr) { try { return Android.toTeX(expr); } catch (e) { return '错误:' + e.message; } },
  790. renderLatex: function(latex) { symjaConsole.addOutput(latex, 'result', { renderLatex: true }); return '已渲染 LaTeX'; }
  791. },
  792. // ── IJS.canvas 绘图 ────────────────────────────
  793. canvas: {
  794. _ctx: null,
  795. _canvas: null,
  796. _frameId: null,
  797. create: function(w, h) {
  798. const panel = document.getElementById('fullscreenContent');
  799. if (!panel) return null;
  800. panel.innerHTML = '';
  801. const c = document.createElement('canvas');
  802. const dpr = window.devicePixelRatio || 1;
  803. // 自适应:未指定尺寸则填满全屏面板
  804. const cw = w || panel.clientWidth || window.innerWidth;
  805. const ch = h || panel.clientHeight || window.innerHeight;
  806. c.width = cw * dpr;
  807. c.height = ch * dpr;
  808. c.style.width = cw + 'px';
  809. c.style.height = ch + 'px';
  810. c.style.maxWidth = '100%';
  811. c.style.maxHeight = '100%';
  812. this._canvas = c;
  813. this._ctx = c.getContext('2d');
  814. this._ctx.scale(dpr, dpr);
  815. this._logicalW = cw;
  816. this._logicalH = ch;
  817. // 同步给 input.xy() 的闭包引用
  818. canvasRef._canvas = c;
  819. canvasRef._logicalW = cw;
  820. canvasRef._logicalH = ch;
  821. panel.appendChild(c);
  822. return this._ctx;
  823. },
  824. /** 获取逻辑画布宽度(供插件做自适应布局) */
  825. width: function() { return this._logicalW || 600; },
  826. /** 获取逻辑画布高度 */
  827. height: function() { return this._logicalH || 800; },
  828. getContext: function() { return this._ctx; },
  829. getSize: function() {
  830. const dpr = window.devicePixelRatio || 1;
  831. return this._canvas ? { width: this._canvas.width / dpr, height: this._canvas.height / dpr } : { width: 0, height: 0 };
  832. },
  833. clear: function() {
  834. if (this._ctx && this._canvas) {
  835. const dpr = window.devicePixelRatio || 1;
  836. this._ctx.clearRect(0, 0, this._canvas.width / dpr, this._canvas.height / dpr);
  837. }
  838. },
  839. fill: function(color) {
  840. if (this._ctx && this._canvas) {
  841. const dpr = window.devicePixelRatio || 1;
  842. this._ctx.fillStyle = color || '#FFFFFF';
  843. this._ctx.fillRect(0, 0, this._canvas.width / dpr, this._canvas.height / dpr);
  844. }
  845. },
  846. size: function(w, h) {
  847. if (this._canvas) {
  848. this._canvas.width = w;
  849. this._canvas.height = h;
  850. this._logicalW = w;
  851. this._logicalH = h;
  852. canvasRef._logicalW = w;
  853. canvasRef._logicalH = h;
  854. }
  855. },
  856. // 动画循环
  857. loop: function(callback) {
  858. const selfCanvas = this;
  859. function frame() {
  860. selfCanvas._frameId = requestAnimationFrame(frame);
  861. callback();
  862. }
  863. this.stop();
  864. selfCanvas._frameId = requestAnimationFrame(frame);
  865. },
  866. stop: function() {
  867. if (this._frameId) {
  868. cancelAnimationFrame(this._frameId);
  869. this._frameId = null;
  870. }
  871. }
  872. },
  873. // ── IJS.input 触摸交互 ──────────────────────────
  874. input: {
  875. _callbacks: {},
  876. onTouchStart: function(cb) { this._callbacks.touchstart = cb; },
  877. onTouchMove: function(cb) { this._callbacks.touchmove = cb; },
  878. onTouchEnd: function(cb) { this._callbacks.touchend = cb; this._bind(); },
  879. /** 模拟触摸(MCP 调试用) */
  880. _fireTouch: function(x, y) {
  881. if (this._callbacks.touchend) {
  882. this._callbacks.touchend({
  883. changedTouches: [{clientX: x, clientY: y}],
  884. preventDefault: function(){}
  885. });
  886. }
  887. },
  888. /** 将触摸事件的 clientX/Y 转换为画布逻辑坐标 */
  889. xy: function(e) {
  890. var touch = e.changedTouches ? e.changedTouches[0] : (e.touches ? e.touches[0] : e);
  891. if (!touch) return {x:0,y:0};
  892. var c = canvasRef._canvas;
  893. if (!c) return {x: touch.clientX, y: touch.clientY};
  894. var r = c.getBoundingClientRect();
  895. return {
  896. x: (touch.clientX - r.left) * (canvasRef._logicalW / r.width),
  897. y: (touch.clientY - r.top) * (canvasRef._logicalH / r.height)
  898. };
  899. },
  900. _bound: false,
  901. _bind: function() {
  902. if (this._bound) return;
  903. this._bound = true;
  904. const panel = document.getElementById('fullscreenContent');
  905. if (!panel) return;
  906. const selfInput = this;
  907. panel.addEventListener('touchstart', function(e) {
  908. e.preventDefault();
  909. if (selfInput._callbacks.touchstart) selfInput._callbacks.touchstart(e);
  910. }, { passive: false });
  911. panel.addEventListener('touchmove', function(e) {
  912. e.preventDefault();
  913. if (selfInput._callbacks.touchmove) selfInput._callbacks.touchmove(e);
  914. }, { passive: false });
  915. panel.addEventListener('touchend', function(e) {
  916. if (selfInput._callbacks.touchend) selfInput._callbacks.touchend(e);
  917. });
  918. }
  919. },
  920. // ── IJS.io 文件读写 (应用数据目录) ────────────────
  921. io: {
  922. read: function(filename) {
  923. try { return Android.ioRead(filename); } catch (e) { return null; }
  924. },
  925. write: function(filename, content) {
  926. try { Android.ioWrite(filename, content); return true; } catch (e) { return false; }
  927. },
  928. list: function() {
  929. try { return JSON.parse(Android.ioList()); } catch (e) { return []; }
  930. },
  931. delete: function(filename) {
  932. try { Android.ioDelete(filename); return true; } catch (e) { return false; }
  933. }
  934. },
  935. // ── IJS.audio 音效 ──────────────────────────────
  936. audio: {
  937. _ctx: null,
  938. _getCtx: function() {
  939. if (!this._ctx) this._ctx = new (window.AudioContext || window.webkitAudioContext)();
  940. return this._ctx;
  941. },
  942. beep: function(freq, duration, type) {
  943. try {
  944. const ctx = this._getCtx();
  945. const osc = ctx.createOscillator();
  946. const gain = ctx.createGain();
  947. osc.type = type || 'square';
  948. osc.frequency.value = freq || 440;
  949. gain.gain.value = 0.15;
  950. gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + (duration || 0.2));
  951. osc.connect(gain);
  952. gain.connect(ctx.destination);
  953. osc.start(ctx.currentTime);
  954. osc.stop(ctx.currentTime + (duration || 0.2));
  955. } catch(e) {}
  956. },
  957. playNote: function(note, octave, duration) {
  958. const notes = { C: 262, D: 294, E: 330, F: 349, G: 392, A: 440, B: 494 };
  959. const freq = (notes[note] || 440) * Math.pow(2, (octave || 4) - 4);
  960. this.beep(freq, duration || 0.2, 'square');
  961. },
  962. playMelody: function(melody, tempo) {
  963. let t = 0;
  964. const beat = (tempo || 120) / 60;
  965. const selfAudio = this;
  966. melody.forEach(function(m) {
  967. if (m.note === 'R') { t += m.dur / beat; return; }
  968. setTimeout(function() {
  969. selfAudio.playNote(m.note, m.octave || 4, m.dur / beat * 0.9);
  970. }, t * 1000);
  971. t += m.dur / beat;
  972. });
  973. },
  974. playFile: function(filename) {
  975. // 暂不支持,留待扩展
  976. }
  977. },
  978. // ── IJS.ui 面板控制 ─────────────────────────────
  979. ui: {
  980. close: function() {
  981. document.getElementById('fullscreenPanel').classList.remove('show');
  982. },
  983. setBackground: function(color) {
  984. const panel = document.getElementById('fullscreenContent');
  985. if (panel) panel.style.background = color;
  986. }
  987. }
  988. };
  989. }
  990. trackVariables(expression) {
  991. // 简单的变量赋值检测
  992. const match = expression.match(/^([a-zA-Z]\w*)\s*=/);
  993. if (match) {
  994. this.variables.add(match[1]);
  995. }
  996. }
  997. clearVariable(varName) {
  998. try {
  999. const result = Android.clearVariable(varName);
  1000. this.addOutput(result, 'help');
  1001. this.variables.delete(varName);
  1002. } catch (e) {
  1003. this.addOutput(`❌ 清除失败: ${e.message}`, 'error');
  1004. }
  1005. this.scrollToBottom();
  1006. }
  1007. showHistory() {
  1008. if (this.history.length === 0) {
  1009. this.addOutput('📜 历史记录为空', 'help');
  1010. } else {
  1011. const recent = this.history.slice(-10).reverse();
  1012. const historyText = recent.map((cmd, i) => `${recent.length - i}. ${cmd}`).join('\n');
  1013. this.addOutput(`📜 最近的命令:\n${historyText}`, 'help');
  1014. }
  1015. this.scrollToBottom();
  1016. }
  1017. showMcpAddress() {
  1018. try {
  1019. const running = typeof Android !== 'undefined' && Android.isMcpRunning && Android.isMcpRunning();
  1020. const port = '8734';
  1021. const status = running ? '✅ 运行中' : '⭕ 未启动';
  1022. const rikkaConfig = JSON.stringify({
  1023. mcpServers: {
  1024. ijs: {
  1025. type: "streamable-http",
  1026. url: `http://127.0.0.1:${port}/mcp`,
  1027. commonOptions: {
  1028. name: "IJS - Symja 符号计算",
  1029. enable: true,
  1030. headers: []
  1031. }
  1032. }
  1033. }
  1034. }, null, 2);
  1035. 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');
  1036. } catch(e) {
  1037. this.addOutput('🌐 MCP 服务器\n 地址: http://127.0.0.1:8734/mcp', 'help');
  1038. }
  1039. this.scrollToBottom();
  1040. }
  1041. showFeedback() {
  1042. const email = 'nonvioce@qq.com';
  1043. const feedbackId = 'feedback-' + Date.now();
  1044. // 创建带复制按钮的反馈信息
  1045. const line = document.createElement('div');
  1046. line.className = 'terminal-line';
  1047. line.innerHTML = `
  1048. <div class="feedback-line">
  1049. <div class="feedback-icon">📧</div>
  1050. <div class="feedback-content">
  1051. <div class="feedback-title">反馈与建议</div>
  1052. <div class="feedback-text">
  1053. 欢迎通过邮件向我们反馈问题或提出建议!
  1054. </div>
  1055. <div class="feedback-email-container">
  1056. <span class="feedback-email">${email}</span>
  1057. <button class="feedback-copy-btn" onclick="symjaConsole.copyFeedbackEmail('${email}', this)">
  1058. 📋 复制邮箱
  1059. </button>
  1060. </div>
  1061. </div>
  1062. </div>
  1063. `;
  1064. this.output.appendChild(line);
  1065. this.scrollToBottom();
  1066. }
  1067. copyFeedbackEmail(email, button) {
  1068. // 创建临时文本区域
  1069. const textarea = document.createElement('textarea');
  1070. textarea.value = email;
  1071. textarea.style.position = 'fixed';
  1072. textarea.style.opacity = '0';
  1073. document.body.appendChild(textarea);
  1074. textarea.select();
  1075. try {
  1076. document.execCommand('copy');
  1077. button.textContent = '✅ 已复制';
  1078. button.style.background = '#4CAF50';
  1079. setTimeout(() => {
  1080. button.textContent = '📋 复制邮箱';
  1081. button.style.background = '';
  1082. }, 2000);
  1083. } catch (e) {
  1084. console.error('Copy failed:', e);
  1085. button.textContent = '❌ 复制失败';
  1086. setTimeout(() => {
  1087. button.textContent = '📋 复制邮箱';
  1088. }, 2000);
  1089. }
  1090. document.body.removeChild(textarea);
  1091. }
  1092. addToHistory(cmd) {
  1093. // 避免重复的连续命令
  1094. if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) {
  1095. this.history.push(cmd);
  1096. // 限制历史记录大小
  1097. if (this.history.length > 100) {
  1098. this.history.shift();
  1099. }
  1100. this.saveHistory();
  1101. }
  1102. this.historyIndex = this.history.length;
  1103. }
  1104. loadHistory() {
  1105. try {
  1106. if (typeof Android !== 'undefined' && Android.loadHistory) {
  1107. const saved = Android.loadHistory();
  1108. return saved ? JSON.parse(saved) : [];
  1109. }
  1110. } catch (e) {}
  1111. // 回退到 localStorage
  1112. try {
  1113. const saved = localStorage.getItem('symja_history');
  1114. return saved ? JSON.parse(saved) : [];
  1115. } catch (e) { return []; }
  1116. }
  1117. saveHistory() {
  1118. const json = JSON.stringify(this.history);
  1119. try {
  1120. if (typeof Android !== 'undefined' && Android.saveHistory) {
  1121. Android.saveHistory(json);
  1122. }
  1123. } catch (e) {}
  1124. try {
  1125. localStorage.setItem('symja_history', json);
  1126. } catch (e) {}
  1127. }
  1128. saveSession(name) {
  1129. try {
  1130. const session = {
  1131. history: this.history,
  1132. variables: Array.from(this.variables),
  1133. timestamp: new Date().toISOString()
  1134. };
  1135. localStorage.setItem(`symja_session_${name}`, JSON.stringify(session));
  1136. this.addOutput(`💾 会话已保存: ${name}`, 'help');
  1137. } catch (e) {
  1138. this.addOutput(`❌ 保存失败: ${e.message}`, 'error');
  1139. }
  1140. this.scrollToBottom();
  1141. }
  1142. addOutput(text, type, options = {}) {
  1143. const line = document.createElement('div');
  1144. line.className = 'terminal-line';
  1145. if (type === 'input') {
  1146. // 命令输入显示(带计数器)
  1147. const count = options.count !== undefined ? options.count : '';
  1148. line.innerHTML = `
  1149. <div class="command-line">
  1150. <span class="command-prompt">[${count}]:</span>
  1151. <span class="command-text">${this.escapeHtml(text)}</span>
  1152. </div>
  1153. `;
  1154. } else if (type === 'result') {
  1155. const isError = text.startsWith('错误:');
  1156. // 检查是否需要 LaTeX 渲染
  1157. if (options.renderLatex && !isError) {
  1158. const count = options.count !== undefined ? options.count : '';
  1159. line.innerHTML = `
  1160. <div class="latex-line">
  1161. <div class="result-prompt">[${count}]:</div>
  1162. <div class="latex-content">
  1163. <div class="latex-rendered" id="latex-${Date.now()}"></div>
  1164. <div class="latex-source" style="display: none;">${this.escapeHtml(text)}</div>
  1165. <div class="latex-actions">
  1166. <button class="latex-btn latex-toggle" onclick="symjaConsole.toggleLatexSource(this)">显示源码</button>
  1167. <button class="latex-btn latex-copy" onclick="symjaConsole.copyLatex(this)">复制 LaTeX</button>
  1168. </div>
  1169. </div>
  1170. </div>
  1171. `;
  1172. this.output.appendChild(line);
  1173. // 渲染 LaTeX
  1174. const renderedDiv = line.querySelector('.latex-rendered');
  1175. this.renderLatex(text, renderedDiv);
  1176. } else {
  1177. const className = isError ? 'error-line' : 'result-line';
  1178. const count = options.count !== undefined ? options.count : '';
  1179. line.innerHTML = `
  1180. <div class="${className}">
  1181. <span class="result-prompt">[${count}]:</span>
  1182. <span class="result-text">${this.escapeHtml(text)}</span>
  1183. </div>
  1184. `;
  1185. }
  1186. } else if (type === 'help') {
  1187. line.innerHTML = `<div class="info-line">${this.escapeHtml(text)}</div>`;
  1188. } else if (type === 'error') {
  1189. line.innerHTML = `<div class="error-line">${this.escapeHtml(text)}</div>`;
  1190. } else if (type === 'plugin-info') {
  1191. // 插件的控制台输出(通过 Symja.print() 输出的内容)
  1192. line.innerHTML = `<div class="plugin-info-line">${this.escapeHtml(text)}</div>`;
  1193. } else if (type === 'plugin') {
  1194. // 插件的返回值(可运算结果)
  1195. line.innerHTML = `<div class="plugin-line">${this.escapeHtml(text)}</div>`;
  1196. } else if (type === 'mcp-input') {
  1197. const seq = options.seq || '';
  1198. line.innerHTML = `<div class="mcp-input-line">
  1199. <span class="mcp-badge">🌐 MCP</span>
  1200. <span class="mcp-prompt">[${seq}]:</span>
  1201. <span class="mcp-command-text">${this.escapeHtml(text)}</span>
  1202. </div>`;
  1203. } else if (type === 'mcp-latex') {
  1204. line.innerHTML = `<div class="latex-line">
  1205. <div class="latex-content">
  1206. <div class="latex-rendered" id="latex-mcp-${Date.now()}"></div>
  1207. <div class="latex-source" style="display:none">${this.escapeHtml(text)}</div>
  1208. <div class="latex-actions">
  1209. <button class="latex-btn latex-toggle" onclick="symjaConsole.toggleLatexSource(this)">显示源码</button>
  1210. <button class="latex-btn latex-copy" onclick="symjaConsole.copyLatex(this)">复制 LaTeX</button>
  1211. </div>
  1212. </div>
  1213. </div>`;
  1214. this.output.appendChild(line);
  1215. const renderedDiv = line.querySelector('.latex-rendered');
  1216. this.renderLatex(text, renderedDiv);
  1217. } else if (type === 'mcp-result') {
  1218. line.innerHTML = `<div class="mcp-result-line">
  1219. <span class="mcp-prompt"></span>
  1220. <span class="mcp-result-text">${this.escapeHtml(text)}</span>
  1221. </div>`;
  1222. }
  1223. this.output.appendChild(line);
  1224. }
  1225. // LaTeX 渲染功能
  1226. renderLatex(latex, element) {
  1227. try {
  1228. // 等待 KaTeX 加载
  1229. if (typeof katex !== 'undefined') {
  1230. // 清理 LaTeX 字符串
  1231. let cleanLatex = latex.trim();
  1232. // 如果是 Symja 的 TeXForm 输出,可能需要一些转换
  1233. if (cleanLatex.startsWith('TeXForm:')) {
  1234. cleanLatex = cleanLatex.substring(8).trim();
  1235. }
  1236. // 使用 KaTeX 渲染
  1237. katex.render(cleanLatex, element, {
  1238. throwOnError: false,
  1239. displayMode: true,
  1240. output: 'html',
  1241. trust: false,
  1242. strict: false
  1243. });
  1244. } else {
  1245. // KaTeX 未加载,显示格式化的源码
  1246. element.style.fontFamily = 'var(--font-mono)';
  1247. element.style.fontSize = '14px';
  1248. element.style.padding = '12px';
  1249. element.style.background = '#F5F5F5';
  1250. element.style.borderRadius = '4px';
  1251. element.style.overflowX = 'auto';
  1252. element.textContent = latex;
  1253. console.warn('KaTeX not loaded, showing source code');
  1254. }
  1255. } catch (e) {
  1256. // 渲染失败,显示源码
  1257. element.style.fontFamily = 'var(--font-mono)';
  1258. element.style.fontSize = '14px';
  1259. element.style.padding = '12px';
  1260. element.style.background = '#F5F5F5';
  1261. element.style.borderRadius = '4px';
  1262. element.style.overflowX = 'auto';
  1263. element.textContent = latex;
  1264. console.error('LaTeX rendering error:', e);
  1265. }
  1266. }
  1267. // 切换显示 LaTeX 源码
  1268. toggleLatexSource(button) {
  1269. const parent = button.closest('.latex-line');
  1270. if (!parent) return;
  1271. const source = parent.querySelector('.latex-source');
  1272. if (!source) return;
  1273. if (source.style.display === 'none') {
  1274. source.style.display = 'block';
  1275. button.textContent = '隐藏源码';
  1276. } else {
  1277. source.style.display = 'none';
  1278. button.textContent = '显示源码';
  1279. }
  1280. }
  1281. // 复制 LaTeX 代码
  1282. copyLatex(button) {
  1283. const parent = button.closest('.latex-line');
  1284. if (!parent) return;
  1285. const source = parent.querySelector('.latex-source');
  1286. if (!source) return;
  1287. const text = source.textContent;
  1288. // 创建临时文本区域
  1289. const textarea = document.createElement('textarea');
  1290. textarea.value = text;
  1291. textarea.style.position = 'fixed';
  1292. textarea.style.opacity = '0';
  1293. document.body.appendChild(textarea);
  1294. textarea.select();
  1295. try {
  1296. document.execCommand('copy');
  1297. button.textContent = '已复制!';
  1298. setTimeout(() => {
  1299. button.textContent = '复制 LaTeX';
  1300. }, 2000);
  1301. } catch (e) {
  1302. console.error('Copy failed:', e);
  1303. }
  1304. document.body.removeChild(textarea);
  1305. }
  1306. showHelp() {
  1307. const helpPage = document.getElementById('helpPage');
  1308. const helpContent = document.getElementById('helpContent');
  1309. if (!helpPage || !helpContent) return;
  1310. // 隐藏键盘
  1311. this.hideKeyboard();
  1312. helpContent.innerHTML = `
  1313. <table class="help-table">
  1314. <thead>
  1315. <tr>
  1316. <th>说明</th>
  1317. <th>命令</th>
  1318. </tr>
  1319. </thead>
  1320. <tbody>
  1321. <tr><td>求导</td><td>D[x^2, x]</td></tr>
  1322. <tr><td>复合函数求导</td><td>D[Sin[x]*Cos[x], x]</td></tr>
  1323. <tr><td>不定积分</td><td>Integrate[x^2, x]</td></tr>
  1324. <tr><td>定积分</td><td>Integrate[x^2, {x, 0, 1}]</td></tr>
  1325. <tr><td>求极限</td><td>Limit[Sin[x]/x, x -> 0]</td></tr>
  1326. <tr><td>泰勒展开</td><td>Series[Exp[x], {x, 0, 5}]</td></tr>
  1327. <tr><td>求和</td><td>Sum[i^2, {i, 1, 10}]</td></tr>
  1328. <tr><td>求积(阶乘)</td><td>Product[i, {i, 1, 5}]</td></tr>
  1329. <tr><td>展开</td><td>Expand[(x+1)^3]</td></tr>
  1330. <tr><td>因式分解</td><td>Factor[x^2-4]</td></tr>
  1331. <tr><td>化简</td><td>Simplify[Sin[x]^2+Cos[x]^2]</td></tr>
  1332. <tr><td>通分</td><td>Together[(1/x)+(1/y)]</td></tr>
  1333. <tr><td>部分分式</td><td>Apart[(x+1)/(x^2-1), x]</td></tr>
  1334. <tr><td>约分</td><td>Cancel[(x^2-1)/(x-1)]</td></tr>
  1335. <tr><td>合并同类项</td><td>Collect[x^2+3*x+2*x^2, x]</td></tr>
  1336. <tr><td>解方程</td><td>Solve[x^2-5*x+6==0, x]</td></tr>
  1337. <tr><td>方程组</td><td>Solve[{x+y==1, x-y==3}, {x,y}]</td></tr>
  1338. <tr><td>数值求解</td><td>NSolve[x^3-2*x+1==0, x]</td></tr>
  1339. <tr><td>求根</td><td>Roots[x^2-4==0, x]</td></tr>
  1340. <tr><td>行列式</td><td>Det[{{1,2},{3,4}}]</td></tr>
  1341. <tr><td>逆矩阵</td><td>Inverse[{{1,2},{3,4}}]</td></tr>
  1342. <tr><td>转置</td><td>Transpose[{{1,2},{3,4}}]</td></tr>
  1343. <tr><td>特征值</td><td>Eigenvalues[{{1,2},{3,4}}]</td></tr>
  1344. <tr><td>特征向量</td><td>Eigenvectors[{{1,2},{3,4}}]</td></tr>
  1345. <tr><td>矩阵秩</td><td>MatrixRank[{{1,2},{2,4}}]</td></tr>
  1346. <tr><td>最大公约数</td><td>GCD[12, 18]</td></tr>
  1347. <tr><td>最小公倍数</td><td>LCM[12, 18]</td></tr>
  1348. <tr><td>质因数分解</td><td>FactorInteger[60]</td></tr>
  1349. <tr><td>判断素数</td><td>PrimeQ[17]</td></tr>
  1350. <tr><td>第 n 个素数</td><td>Prime[10]</td></tr>
  1351. <tr><td>斐波那契数</td><td>Fibonacci[10]</td></tr>
  1352. <tr><td>组合数 C(n,k)</td><td>Binomial[5, 2]</td></tr>
  1353. <tr><td>高精度计算(50位)</td><td>N[Pi, 50]</td></tr>
  1354. <tr><td>数值化</td><td>N[Sqrt[2]]</td></tr>
  1355. <tr><td>四舍五入</td><td>Round[3.7]</td></tr>
  1356. <tr><td>向下取整</td><td>Floor[3.7]</td></tr>
  1357. <tr><td>向上取整</td><td>Ceiling[3.2]</td></tr>
  1358. <tr><td>三角函数</td><td>Sin[x], Cos[x], Tan[x]</td></tr>
  1359. <tr><td>反三角函数</td><td>ArcSin[x], ArcCos[x], ArcTan[x]</td></tr>
  1360. <tr><td>双曲函数</td><td>Sinh[x], Cosh[x], Tanh[x]</td></tr>
  1361. <tr><td>三角展开</td><td>TrigExpand[Sin[2*x]]</td></tr>
  1362. <tr><td>三角化简</td><td>TrigReduce[Sin[x]^2]</td></tr>
  1363. <tr><td>实部</td><td>Re[3+4*I]</td></tr>
  1364. <tr><td>虚部</td><td>Im[3+4*I]</td></tr>
  1365. <tr><td>模</td><td>Abs[3+4*I]</td></tr>
  1366. <tr><td>辐角</td><td>Arg[3+4*I]</td></tr>
  1367. <tr><td>共轭</td><td>Conjugate[3+4*I]</td></tr>
  1368. <tr><td>生成表格</td><td>Table[i^2, {i, 1, 10}]</td></tr>
  1369. <tr><td>生成数列</td><td>Range[1, 10]</td></tr>
  1370. <tr><td>带步长的数列</td><td>Range[0, 1, 0.1]</td></tr>
  1371. <tr><td>列表长度</td><td>Length[{1,2,3,4}]</td></tr>
  1372. <tr><td>第一个元素</td><td>First[{1,2,3}]</td></tr>
  1373. <tr><td>最后一个元素</td><td>Last[{1,2,3}]</td></tr>
  1374. <tr><td>取前 n 个</td><td>Take[{1,2,3,4,5}, 3]</td></tr>
  1375. <tr><td>删除前 n 个</td><td>Drop[{1,2,3,4,5}, 2]</td></tr>
  1376. <tr><td>连接列表</td><td>Join[{1,2}, {3,4}]</td></tr>
  1377. <tr><td>反转列表</td><td>Reverse[{1,2,3}]</td></tr>
  1378. <tr><td>排序</td><td>Sort[{3,1,4,1,5}]</td></tr>
  1379. <tr><td>替换规则</td><td>b+c /. b->1</td></tr>
  1380. <tr><td>变量替换</td><td>x^2+2*x+1 /. x->3</td></tr>
  1381. <tr><td>多重替换</td><td>expr /. {a->1, b->2}</td></tr>
  1382. <tr><td>微分方程</td><td>DSolve[y'[x]==y[x], y[x], x]</td></tr>
  1383. <tr><td>递推方程</td><td>RSolve[a[n]==2*a[n-1]+1, a[n], n]</td></tr>
  1384. <tr><td>拉普拉斯变换</td><td>LaplaceTransform[Sin[t], t, s]</td></tr>
  1385. <tr><td>逆拉普拉斯变换</td><td>InverseLaplaceTransform[1/(s^2+1), s, t]</td></tr>
  1386. <tr><td>傅里叶变换</td><td>FourierTransform[Exp[-x^2], x, w]</td></tr>
  1387. <tr><td>求最大值</td><td>Maximize[x^2-4*x+3, x]</td></tr>
  1388. <tr><td>求最小值</td><td>Minimize[x^2-4*x+3, x]</td></tr>
  1389. <tr><td>平均值</td><td>Mean[{1,2,3,4,5}]</td></tr>
  1390. <tr><td>中位数</td><td>Median[{1,2,3,4,5}]</td></tr>
  1391. <tr><td>方差</td><td>Variance[{1,2,3,4,5}]</td></tr>
  1392. <tr><td>标准差</td><td>StandardDeviation[{1,2,3,4,5}]</td></tr>
  1393. <tr><td>LaTeX 格式</td><td>TeXForm[x^2 + 2*x + 1]</td></tr>
  1394. <tr><td>完整形式</td><td>FullForm[x+y]</td></tr>
  1395. <tr><td>输入形式</td><td>InputForm[expr]</td></tr>
  1396. <tr><td>显示帮助</td><td>help 或 ?</td></tr>
  1397. <tr><td>清空输出</td><td>clear 或 cls</td></tr>
  1398. <tr><td>命令历史</td><td>history</td></tr>
  1399. <tr><td>清除变量</td><td>clear &lt;变量&gt;</td></tr>
  1400. <tr><td>插件管理</td><td>plugins</td></tr>
  1401. <tr><td>显示反馈邮箱</td><td>feedback</td></tr>
  1402. <tr><td>圆周率 π</td><td>Pi</td></tr>
  1403. <tr><td>自然常数 e</td><td>E</td></tr>
  1404. <tr><td>虚数单位 i</td><td>I</td></tr>
  1405. <tr><td>无穷大 ∞</td><td>Infinity</td></tr>
  1406. <tr><td>布尔值</td><td>True, False</td></tr>
  1407. <tr><td>上一次的输出结果</td><td>%</td></tr>
  1408. </tbody>
  1409. </table>
  1410. `;
  1411. helpPage.classList.add('show');
  1412. }
  1413. hideHelp() {
  1414. const helpPage = document.getElementById('helpPage');
  1415. if (helpPage) {
  1416. helpPage.classList.remove('show');
  1417. }
  1418. }
  1419. showSettings() {
  1420. const panel = document.getElementById('settingsPanel');
  1421. if (!panel) return;
  1422. this.hideKeyboard();
  1423. document.getElementById('settingMcpEnabled').value = this.getMcpEnabled() ? '1' : '0';
  1424. document.getElementById('settingMcpPort').value = this.getSetting('mcpPort', '8734');
  1425. document.getElementById('settingFontSize').value = this.getSetting('fontSize', '14');
  1426. panel.classList.add('show');
  1427. }
  1428. hideSettings() {
  1429. document.getElementById('settingsPanel')?.classList.remove('show');
  1430. }
  1431. applySettings() {
  1432. const wasEnabled = this.getMcpEnabled();
  1433. const enabled = document.getElementById('settingMcpEnabled').value === '1';
  1434. const port = document.getElementById('settingMcpPort').value;
  1435. const fontSize = document.getElementById('settingFontSize').value;
  1436. this.saveSetting('mcpPort', port);
  1437. this.saveSetting('fontSize', fontSize);
  1438. this.setMcpEnabled(enabled);
  1439. document.querySelector('.terminal-output').style.fontSize = fontSize + 'px';
  1440. document.querySelector('.terminal-input').style.fontSize = fontSize + 'px';
  1441. this.hideSettings();
  1442. this.addOutput('⚙️ 设置已应用', 'help');
  1443. this.scrollToBottom();
  1444. // MCP 状态变更时更新轮询
  1445. if (enabled !== wasEnabled) {
  1446. if (enabled) {
  1447. this.startMcpPollingIfNeeded();
  1448. } else {
  1449. this.stopMcpPolling();
  1450. }
  1451. }
  1452. }
  1453. getMcpEnabled() {
  1454. try {
  1455. if (typeof Android !== 'undefined' && Android.getMcpEnabled !== undefined) {
  1456. return Android.getMcpEnabled();
  1457. }
  1458. } catch(e) {}
  1459. return this.getSetting('mcpEnabled', '1') === '1';
  1460. }
  1461. setMcpEnabled(val) {
  1462. this.saveSetting('mcpEnabled', val ? '1' : '0');
  1463. try {
  1464. if (typeof Android !== 'undefined' && Android.setMcpEnabled) {
  1465. Android.setMcpEnabled(val);
  1466. }
  1467. } catch(e) {}
  1468. }
  1469. startMcpPollingIfNeeded() {
  1470. if (!this.getMcpEnabled()) {
  1471. console.log('MCP polling skipped: getMcpEnabled=false');
  1472. return;
  1473. }
  1474. if (!this.mcpPollTimer) {
  1475. console.log('MCP polling started');
  1476. this.mcpPollTimer = setInterval(() => this.pollMcpEvents(), 1000);
  1477. }
  1478. }
  1479. stopMcpPolling() {
  1480. if (this.mcpPollTimer) { clearInterval(this.mcpPollTimer); this.mcpPollTimer = null; }
  1481. }
  1482. getSetting(key, def) {
  1483. try { return localStorage.getItem('ijs_' + key) || def; } catch(e) { return def; }
  1484. }
  1485. saveSetting(key, val) {
  1486. try { localStorage.setItem('ijs_' + key, val); } catch(e) {}
  1487. }
  1488. loadSettings() {
  1489. const fs = this.getSetting('fontSize', '14');
  1490. try {
  1491. document.querySelector('.terminal-output').style.fontSize = fs + 'px';
  1492. document.querySelector('.terminal-input').style.fontSize = fs + 'px';
  1493. } catch(e) {}
  1494. }
  1495. clear() {
  1496. this.output.innerHTML = '';
  1497. this.showWelcome();
  1498. // 重置计数器
  1499. this.executionCount = 1;
  1500. this.saveExecutionCount();
  1501. this.updatePrompt();
  1502. }
  1503. reset() {
  1504. try {
  1505. const result = Android.reset();
  1506. this.variables.clear();
  1507. this.addOutput('🔄 ' + result, 'help');
  1508. this.scrollToBottom();
  1509. // 重置计数器
  1510. this.executionCount = 1;
  1511. this.saveExecutionCount();
  1512. this.updatePrompt();
  1513. } catch (e) {
  1514. this.addOutput('错误: 无法重置计算器', 'error');
  1515. }
  1516. }
  1517. updatePrompt() {
  1518. if (this.inputPrompt) {
  1519. this.inputPrompt.textContent = `[${this.executionCount}]:`;
  1520. }
  1521. }
  1522. loadExecutionCount() {
  1523. return 1; // 每次打开应用从1开始
  1524. }
  1525. saveExecutionCount() {
  1526. // 不持久化编号,每次打开都重置
  1527. }
  1528. navigateHistory(direction) {
  1529. if (this.history.length === 0) {
  1530. return;
  1531. }
  1532. // 保存当前输入
  1533. if (this.historyIndex === this.history.length) {
  1534. this.tempInput = this.input.value;
  1535. }
  1536. this.historyIndex += direction;
  1537. if (this.historyIndex < 0) {
  1538. this.historyIndex = 0;
  1539. } else if (this.historyIndex >= this.history.length) {
  1540. this.historyIndex = this.history.length;
  1541. this.input.value = this.tempInput;
  1542. return;
  1543. }
  1544. this.input.value = this.history[this.historyIndex];
  1545. // 光标移到末尾
  1546. setTimeout(() => {
  1547. this.input.setSelectionRange(this.input.value.length, this.input.value.length);
  1548. }, 0);
  1549. }
  1550. // 自动补全功能
  1551. updateCompletionList() {
  1552. const value = this.input.value;
  1553. const cursorPos = this.input.selectionStart;
  1554. const textBeforeCursor = value.substring(0, cursorPos);
  1555. const match = textBeforeCursor.match(/[A-Za-z0-9_-]+$/);
  1556. console.log('updateCompletionList:', { value, textBeforeCursor, match });
  1557. if (match && match[0].length >= 2) {
  1558. const prefix = match[0];
  1559. console.log('Searching completions for prefix:', prefix);
  1560. // 特殊命令列表
  1561. const specialCommands = ['help', 'clear', 'cls', 'history', 'plugins', 'mcp', 'feedback'];
  1562. // 获取插件命令列表
  1563. const pluginCommands = this.plugins
  1564. .filter(p => p.command && p.command.length > 0)
  1565. .map(p => p.command);
  1566. try {
  1567. const completionsJson = Android.getCompletions(prefix);
  1568. console.log('Raw completions JSON:', completionsJson);
  1569. // 解析补全结果 - 可能是逗号分隔的字符串或 JSON 数组
  1570. let symjaCompletions = [];
  1571. if (completionsJson.startsWith('[')) {
  1572. // JSON 数组格式
  1573. symjaCompletions = JSON.parse(completionsJson);
  1574. } else {
  1575. // 逗号分隔的字符串格式
  1576. symjaCompletions = completionsJson.split(',').map(s => s.trim()).filter(s => s.length > 0);
  1577. }
  1578. // 添加匹配的特殊命令
  1579. const lowerPrefix = prefix.toLowerCase();
  1580. const matchingCommands = specialCommands.filter(cmd => cmd.startsWith(lowerPrefix));
  1581. // 添加匹配的插件命令
  1582. const matchingPluginCommands = pluginCommands.filter(cmd => cmd.toLowerCase().startsWith(lowerPrefix));
  1583. // 合并并去重(插件命令优先显示)
  1584. this.completions = [...new Set([...matchingPluginCommands, ...matchingCommands, ...symjaCompletions])];
  1585. console.log('Parsed completions:', this.completions);
  1586. if (this.completions.length > 0) {
  1587. this.showCompletionList();
  1588. } else {
  1589. this.hideCompletionList();
  1590. }
  1591. } catch (e) {
  1592. console.error('Completion error:', e);
  1593. this.completions = [];
  1594. this.hideCompletionList();
  1595. }
  1596. } else {
  1597. this.completions = [];
  1598. this.hideCompletionList();
  1599. }
  1600. }
  1601. showCompletionList() {
  1602. console.log('showCompletionList called');
  1603. console.log('completionList element:', this.completionList);
  1604. console.log('completions:', this.completions);
  1605. if (!this.completionList) {
  1606. console.error('completionList element not found');
  1607. return;
  1608. }
  1609. if (this.completions.length === 0) {
  1610. console.log('No completions to show');
  1611. return;
  1612. }
  1613. this.completionList.innerHTML = '';
  1614. // 最多显示 8 个补全项
  1615. const itemsToShow = this.completions.slice(0, 8);
  1616. console.log('Items to show:', itemsToShow);
  1617. itemsToShow.forEach(completion => {
  1618. const item = document.createElement('div');
  1619. item.className = 'completion-item';
  1620. // 检查是否是插件命令
  1621. const plugin = this.plugins.find(p => p.command === completion);
  1622. if (plugin) {
  1623. // 插件命令,显示图标和名称
  1624. item.innerHTML = `<span style="margin-right: 8px;">${plugin.icon}</span>${completion} <span style="color: #999; font-size: 12px; margin-left: 8px;">(${plugin.name})</span>`;
  1625. } else {
  1626. // 普通命令或函数
  1627. item.textContent = completion;
  1628. }
  1629. item.addEventListener('click', () => {
  1630. this.applyCompletion(completion);
  1631. });
  1632. this.completionList.appendChild(item);
  1633. });
  1634. this.completionList.classList.add('show');
  1635. console.log('Completion list should now be visible');
  1636. }
  1637. hideCompletionList() {
  1638. if (this.completionList) {
  1639. this.completionList.classList.remove('show');
  1640. }
  1641. }
  1642. applyCompletion(completion) {
  1643. const value = this.input.value;
  1644. const cursorPos = this.input.selectionStart;
  1645. const textBeforeCursor = value.substring(0, cursorPos);
  1646. const textAfterCursor = value.substring(cursorPos);
  1647. const match = textBeforeCursor.match(/[A-Za-z0-9_-]+$/);
  1648. if (match) {
  1649. const prefix = match[0];
  1650. const beforePrefix = textBeforeCursor.substring(0, textBeforeCursor.length - prefix.length);
  1651. // 检查是否是插件命令
  1652. const plugin = this.plugins.find(p => p.command === completion);
  1653. if (plugin && plugin.inputs && plugin.inputs.length > 0) {
  1654. // 插件命令,添加参数括号和提示
  1655. const paramHints = plugin.inputs.map(input => input.name).join(', ');
  1656. this.input.value = beforePrefix + completion + '[' + paramHints + ']' + textAfterCursor;
  1657. // 将光标放在第一个参数位置
  1658. const newCursorPos = beforePrefix.length + completion.length + 1;
  1659. this.input.setSelectionRange(newCursorPos, newCursorPos + plugin.inputs[0].name.length);
  1660. } else {
  1661. // 普通命令或函数
  1662. this.input.value = beforePrefix + completion + textAfterCursor;
  1663. const newCursorPos = beforePrefix.length + completion.length;
  1664. this.input.setSelectionRange(newCursorPos, newCursorPos);
  1665. }
  1666. }
  1667. this.hideCompletionList();
  1668. this.input.focus();
  1669. }
  1670. handleAutoComplete() {
  1671. // 保留这个方法以防其他地方调用,但不再使用
  1672. this.updateCompletionList();
  1673. }
  1674. // 插件相关功能
  1675. loadPlugins() {
  1676. try {
  1677. const pluginsJson = Android.getPlugins();
  1678. console.log('Loaded plugins JSON:', pluginsJson);
  1679. this.plugins = JSON.parse(pluginsJson);
  1680. console.log('Parsed plugins count:', this.plugins.length);
  1681. console.log('Plugins:', this.plugins);
  1682. } catch (e) {
  1683. console.error('Failed to load plugins:', e);
  1684. this.plugins = [];
  1685. }
  1686. }
  1687. showPluginPanel() {
  1688. this.loadPlugins();
  1689. const panel = document.getElementById('pluginPanel');
  1690. if (!panel) return;
  1691. // 隐藏键盘
  1692. this.hideKeyboard();
  1693. // 确保默认显示插件列表标签
  1694. const tabs = panel.querySelectorAll('.plugin-tab');
  1695. const contents = panel.querySelectorAll('.tab-content');
  1696. tabs.forEach(tab => {
  1697. if (tab.dataset.tab === 'list') {
  1698. tab.classList.add('active');
  1699. } else {
  1700. tab.classList.remove('active');
  1701. }
  1702. });
  1703. contents.forEach(content => {
  1704. if (content.dataset.content === 'list') {
  1705. content.classList.add('active');
  1706. } else {
  1707. content.classList.remove('active');
  1708. }
  1709. });
  1710. // 更新插件下拉列表(用于分享和删除)
  1711. this.updatePluginSelects();
  1712. // 使用统一的刷新方法
  1713. this.refreshPluginList();
  1714. panel.classList.add('show');
  1715. }
  1716. hidePluginPanel() {
  1717. const panel = document.getElementById('pluginPanel');
  1718. if (panel) panel.classList.remove('show');
  1719. }
  1720. openPlugin(plugin) {
  1721. this.currentPlugin = plugin;
  1722. this.hidePluginPanel();
  1723. // Fullscreen mode — open the fullscreen panel
  1724. if (plugin.mode === 'fullscreen') {
  1725. this.openFullscreenPlugin(plugin);
  1726. return;
  1727. }
  1728. // Inline mode — open the standard dialog
  1729. const dialog = document.getElementById('pluginDialog');
  1730. const title = document.getElementById('pluginDialogTitle');
  1731. const desc = document.getElementById('pluginDialogDesc');
  1732. const inputs = document.getElementById('pluginInputs');
  1733. if (!dialog || !title || !desc || !inputs) return;
  1734. title.textContent = plugin.icon + ' ' + plugin.name;
  1735. desc.textContent = plugin.description;
  1736. inputs.innerHTML = '';
  1737. plugin.inputs.forEach(input => {
  1738. const inputGroup = document.createElement('div');
  1739. inputGroup.className = 'input-group';
  1740. inputGroup.innerHTML = `
  1741. <label>${input.label}</label>
  1742. <input type="text"
  1743. name="${input.name}"
  1744. value="${input.defaultValue || ''}"
  1745. placeholder="${input.placeholder || ''}">
  1746. `;
  1747. inputs.appendChild(inputGroup);
  1748. });
  1749. dialog.classList.add('show');
  1750. }
  1751. hidePluginDialog() {
  1752. const dialog = document.getElementById('pluginDialog');
  1753. if (dialog) dialog.classList.remove('show');
  1754. this.currentPlugin = null;
  1755. }
  1756. openFullscreenPlugin(plugin) {
  1757. const panel = document.getElementById('fullscreenPanel');
  1758. document.getElementById('fsPluginTitle').textContent = plugin.icon + ' ' + plugin.name;
  1759. panel.classList.add('show');
  1760. // Run the plugin in fullscreen mode
  1761. const pluginOutputs = [];
  1762. const ijsAPI = this.createIJSAPI(pluginOutputs);
  1763. // Create a default inputs object from plugin defaults
  1764. const inputValues = {};
  1765. if (plugin.inputs) {
  1766. plugin.inputs.forEach(function(inp) {
  1767. inputValues[inp.name] = inp.defaultValue || '';
  1768. });
  1769. }
  1770. // Fullscreen plugins don't return values — they use IJS.canvas/IJS.input
  1771. const func = new Function('inputs', 'IJS', plugin.jsCode + '\ncalculate(inputs, IJS);');
  1772. try {
  1773. func(inputValues, ijsAPI);
  1774. } catch (e) {
  1775. console.error('Fullscreen plugin error:', e);
  1776. ijsAPI.ui.close();
  1777. this.addOutput('插件错误: ' + e.message, 'error');
  1778. this.scrollToBottom();
  1779. }
  1780. }
  1781. executeCurrentPlugin() {
  1782. if (!this.currentPlugin) return;
  1783. const inputsDiv = document.getElementById('pluginInputs');
  1784. if (!inputsDiv) return;
  1785. const inputElements = inputsDiv.querySelectorAll('input');
  1786. const inputValues = {};
  1787. // 收集输入值并转换为 Symja 可运算类型
  1788. inputElements.forEach(input => {
  1789. inputValues[input.name] = this.convertInputToSymjaType(input.value);
  1790. });
  1791. try {
  1792. // 用于存储插件的控制台输出
  1793. const pluginOutputs = [];
  1794. // 创建 IJS API 对象
  1795. const ijsAPI = this.createIJSAPI(pluginOutputs);
  1796. // 执行插件代码
  1797. const func = new Function('inputs', 'IJS', this.currentPlugin.jsCode + '\nreturn calculate(inputs, IJS);');
  1798. const result = func(inputValues, ijsAPI);
  1799. // 验证返回值是否为有效的可运算类型
  1800. if (typeof result !== 'string' || result.trim() === '') {
  1801. throw new Error('插件必须返回非空字符串类型的 Symja 表达式');
  1802. }
  1803. // 验证返回值是否是有效的 Symja 表达式(尝试计算)
  1804. try {
  1805. Android.symjaEval(result);
  1806. } catch (e) {
  1807. throw new Error('插件返回值必须是有效的 Symja 表达式,当前返回: ' + result);
  1808. }
  1809. // 显示插件名称
  1810. this.addOutput(`${this.currentPlugin.icon} ${this.currentPlugin.name}`, 'input');
  1811. // 显示插件的控制台输出(如果有)
  1812. if (pluginOutputs.length > 0) {
  1813. pluginOutputs.forEach(output => {
  1814. this.addOutput(output, 'plugin-info');
  1815. });
  1816. }
  1817. // 显示插件的返回值(可运算结果)
  1818. this.addOutput(result, 'plugin');
  1819. // 将插件结果保存到 Symja 引擎,使其可以通过 % 引用
  1820. try {
  1821. Android.evaluate(`Out = ${result}`);
  1822. } catch (e) {
  1823. console.log('Plugin result storage error:', e);
  1824. }
  1825. this.scrollToBottom();
  1826. this.hidePluginDialog();
  1827. } catch (e) {
  1828. this.addOutput(`插件执行错误: ${e.message}`, 'error');
  1829. }
  1830. }
  1831. scrollToBottom() {
  1832. this.output.scrollTop = this.output.scrollHeight;
  1833. }
  1834. escapeHtml(text) {
  1835. const div = document.createElement('div');
  1836. div.textContent = text;
  1837. return div.innerHTML;
  1838. }
  1839. hideKeyboard() {
  1840. // 移除输入框焦点
  1841. if (this.input) {
  1842. this.input.blur();
  1843. }
  1844. // 调用 Android 接口隐藏键盘
  1845. try {
  1846. if (typeof Android !== 'undefined' && Android.hideKeyboard) {
  1847. Android.hideKeyboard();
  1848. }
  1849. } catch (e) {
  1850. console.log('hideKeyboard not available:', e);
  1851. }
  1852. }
  1853. pollMcpEvents() {
  1854. try {
  1855. if (typeof Android === 'undefined' || !Android.getMcpEvents) return;
  1856. const json = Android.getMcpEvents();
  1857. if (json && json !== '[]') console.log('MCP poll got events:', json.substring(0, 200));
  1858. if (!json || json === '[]') return;
  1859. const events = JSON.parse(json);
  1860. events.forEach(ev => {
  1861. // 调试命令:MCP 触发打开全屏插件
  1862. if (ev.expression.startsWith('##OPEN_PLUGIN##')) {
  1863. var pid = ev.expression.substring(16);
  1864. var pl = this.plugins.find(function(p) { return p.id === pid || p.command === pid; });
  1865. if (pl) this.openPlugin(pl);
  1866. return;
  1867. }
  1868. // 调试命令:模拟画布点击 __tap x y (逻辑坐标)
  1869. if (ev.expression.startsWith('__tap ')) {
  1870. var parts = ev.expression.split(/\s+/);
  1871. var tx = parseFloat(parts[1]), ty = parseFloat(parts[2]);
  1872. if (!isNaN(tx) && !isNaN(ty)) {
  1873. // 将逻辑坐标转换为屏幕 clientX/Y
  1874. var c = document.querySelector('#fullscreenContent canvas');
  1875. if (c) {
  1876. var r = c.getBoundingClientRect();
  1877. var cx = r.left + (tx / (parseInt(c.style.width) || r.width)) * r.width;
  1878. var cy = r.top + (ty / (parseInt(c.style.height) || r.height)) * r.height;
  1879. // 通过 _fireTouch 直接触发插件回调
  1880. try {
  1881. // 获取当前插件的 IJS API 实例,调用 _fireTouch
  1882. var apiInstance = symjaConsole._lastIjsApi;
  1883. if (apiInstance && apiInstance.input && apiInstance.input._fireTouch) {
  1884. apiInstance.input._fireTouch(cx, cy);
  1885. }
  1886. } catch(e) { console.log('_fireTouch error:', e); }
  1887. }
  1888. }
  1889. return;
  1890. }
  1891. this.mcpEventCount++;
  1892. this.addToHistory(ev.expression);
  1893. this.addOutput(ev.expression, 'mcp-input', { seq: this.mcpEventCount });
  1894. // 检测 LaTeX 渲染
  1895. if (ev.result && ev.result.startsWith('##TEXFORM##')) {
  1896. const latex = ev.result.substring(11);
  1897. this.addOutput(latex, 'mcp-latex', { renderLatex: true });
  1898. }
  1899. // 检测绘图结果 → Canvas 渲染
  1900. else if (ev.result && ev.result.startsWith('{"type":"plot"')) {
  1901. this.addPlotOutput(ev.result, this.mcpEventCount);
  1902. } else {
  1903. this.addOutput(ev.result, 'mcp-result', { seq: this.mcpEventCount });
  1904. }
  1905. });
  1906. this.scrollToBottom();
  1907. } catch (e) {
  1908. // Ignore polling errors
  1909. }
  1910. }
  1911. }
  1912. // 初始化控制台
  1913. let symjaConsole;
  1914. document.addEventListener('DOMContentLoaded', () => {
  1915. symjaConsole = new SymjaConsole();
  1916. });
  1917. // ── Canvas 绘图渲染器 ────────────────────────────────────────
  1918. class PlotRenderer {
  1919. constructor() {
  1920. this.canvas = null;
  1921. this.ctx = null;
  1922. this.data = null;
  1923. this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
  1924. this.bounds = { xMin: 0, xMax: 1, yMin: 0, yMax: 1 };
  1925. this.dpr = window.devicePixelRatio || 1;
  1926. this.colors = [
  1927. [0.369, 0.507, 0.710], [0.851, 0.208, 0.125],
  1928. [0.267, 0.612, 0.329], [0.737, 0.349, 0.800],
  1929. [1.0, 0.608, 0.0]
  1930. ];
  1931. }
  1932. render(canvas, data) {
  1933. this.canvas = canvas;
  1934. this.ctx = canvas.getContext('2d');
  1935. this.data = data;
  1936. // 计算全局范围
  1937. this.bounds = this.computeBounds(data);
  1938. const padX = (this.bounds.xMax - this.bounds.xMin) * 0.05 || 0.5;
  1939. const padY = (this.bounds.yMax - this.bounds.yMin) * 0.05 || 0.5;
  1940. this.bounds.xMin -= padX;
  1941. this.bounds.xMax += padX;
  1942. this.bounds.yMin -= padY;
  1943. this.bounds.yMax += padY;
  1944. // 强制等比例:确保圆不变椭圆
  1945. const m = 60, cw = canvas.width - 2 * m, ch = canvas.height - 2 * m;
  1946. const xr = this.bounds.xMax - this.bounds.xMin;
  1947. const yr = this.bounds.yMax - this.bounds.yMin;
  1948. if (xr > 0 && yr > 0) {
  1949. if (xr / cw > yr / ch) {
  1950. const ny = xr * ch / cw;
  1951. const my = (this.bounds.yMin + this.bounds.yMax) / 2;
  1952. this.bounds.yMin = my - ny / 2;
  1953. this.bounds.yMax = my + ny / 2;
  1954. } else {
  1955. const nx = yr * cw / ch;
  1956. const mx = (this.bounds.xMin + this.bounds.xMax) / 2;
  1957. this.bounds.xMin = mx - nx / 2;
  1958. this.bounds.xMax = mx + nx / 2;
  1959. }
  1960. }
  1961. this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
  1962. this.draw();
  1963. // 手势绑定
  1964. this.bindGestures();
  1965. }
  1966. computeBounds(data) {
  1967. let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
  1968. if (!data.elements) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
  1969. for (const el of data.elements) {
  1970. if (!el.points) continue;
  1971. for (const pt of el.points) {
  1972. xMin = Math.min(xMin, pt[0]);
  1973. xMax = Math.max(xMax, pt[0]);
  1974. yMin = Math.min(yMin, pt[1]);
  1975. yMax = Math.max(yMax, pt[1]);
  1976. }
  1977. }
  1978. if (!isFinite(xMin)) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
  1979. return { xMin, xMax, yMin, yMax };
  1980. }
  1981. // ── 坐标变换 ──────────────────────────────────────
  1982. toCanvas(px, py) {
  1983. const w = this.canvas.width, h = this.canvas.height;
  1984. const m = 60; // 边距
  1985. const cw = w - 2 * m, ch = h - 2 * m;
  1986. const t = this.transform;
  1987. const b = this.bounds;
  1988. const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
  1989. return {
  1990. x: m + (px - b.xMin - t.ox * xRange) / (xRange * t.sx) * cw,
  1991. y: h - m - (py - b.yMin - t.oy * yRange) / (yRange * t.sy) * ch
  1992. };
  1993. }
  1994. // ── 主绘制 ────────────────────────────────────────
  1995. draw() {
  1996. const ctx = this.ctx;
  1997. const w = this.canvas.width, h = this.canvas.height;
  1998. const m = 60;
  1999. const cw = w - 2 * m, ch = h - 2 * m;
  2000. const t = this.transform;
  2001. const b = this.bounds;
  2002. // 背景
  2003. ctx.fillStyle = '#FFFFFF';
  2004. ctx.fillRect(0, 0, w, h);
  2005. // 网格
  2006. this.drawGrid(m, cw, ch);
  2007. // 坐标轴
  2008. this.drawAxes(m, cw, ch);
  2009. // 数据曲线
  2010. if (this.data.elements) {
  2011. const useColors = this.colors;
  2012. this.data.elements.forEach((el, idx) => {
  2013. if (!el.points || el.points.length === 0) return;
  2014. const color = el.color || useColors[idx % useColors.length];
  2015. const rgb = `rgb(${Math.round(color[0]*255)},${Math.round(color[1]*255)},${Math.round(color[2]*255)})`;
  2016. if (el.type === 'line' || (el.type === 'point' && this.data.options && this.data.options.joined)) {
  2017. ctx.strokeStyle = rgb;
  2018. ctx.lineWidth = 3 * this.dpr;
  2019. ctx.beginPath();
  2020. for (let i = 0; i < el.points.length; i++) {
  2021. const pt = this.toCanvas(el.points[i][0], el.points[i][1]);
  2022. if (i === 0) ctx.moveTo(pt.x, pt.y);
  2023. else ctx.lineTo(pt.x, pt.y);
  2024. }
  2025. ctx.stroke();
  2026. }
  2027. if (el.type === 'point') {
  2028. ctx.fillStyle = rgb;
  2029. for (const pt of el.points) {
  2030. const p = this.toCanvas(pt[0], pt[1]);
  2031. ctx.beginPath();
  2032. ctx.arc(p.x, p.y, 3 * this.dpr, 0, Math.PI * 2);
  2033. ctx.fill();
  2034. }
  2035. }
  2036. });
  2037. }
  2038. }
  2039. drawGrid(m, cw, ch) {
  2040. const ctx = this.ctx;
  2041. const b = this.bounds;
  2042. const t = this.transform;
  2043. const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
  2044. ctx.strokeStyle = '#E8EDF2';
  2045. ctx.lineWidth = Math.round(1 * this.dpr);
  2046. ctx.fillStyle = '#8B95A5';
  2047. ctx.font = '11px -apple-system, sans-serif';
  2048. ctx.textAlign = 'center';
  2049. const xStep = this.niceStep(xRange, 5);
  2050. const yStep = this.niceStep(yRange, 5);
  2051. // X 网格 + 标签
  2052. let xStart = Math.floor((b.xMin + t.ox * xRange) / xStep) * xStep;
  2053. for (let x = xStart; x <= b.xMin + t.ox * xRange + xRange / t.sx + xStep; x += xStep) {
  2054. const pt = this.toCanvas(x, 0);
  2055. if (pt.x >= m && pt.x <= m + cw) {
  2056. ctx.beginPath();
  2057. ctx.moveTo(pt.x, m);
  2058. ctx.lineTo(pt.x, m + ch);
  2059. ctx.stroke();
  2060. ctx.fillText(this.fmtNum(x), pt.x, m + ch + 16);
  2061. }
  2062. }
  2063. // Y 网格 + 标签
  2064. ctx.textAlign = 'right';
  2065. let yStart = Math.floor((b.yMin + t.oy * yRange) / yStep) * yStep;
  2066. for (let y = yStart; y <= b.yMin + t.oy * yRange + yRange / t.sy + yStep; y += yStep) {
  2067. const pt = this.toCanvas(0, y);
  2068. if (pt.y >= m && pt.y <= m + ch) {
  2069. ctx.beginPath();
  2070. ctx.moveTo(m, pt.y);
  2071. ctx.lineTo(m + cw, pt.y);
  2072. ctx.stroke();
  2073. ctx.fillText(this.fmtNum(y), m - 8, pt.y + 4);
  2074. }
  2075. }
  2076. }
  2077. drawAxes(m, cw, ch) {
  2078. const ctx = this.ctx;
  2079. const b = this.bounds;
  2080. const t = this.transform;
  2081. const origin = this.toCanvas(0, 0);
  2082. ctx.strokeStyle = '#1976D2';
  2083. ctx.lineWidth = Math.round(2 * this.dpr);
  2084. ctx.fillStyle = '#1976D2';
  2085. ctx.font = 'bold 11px -apple-system, sans-serif';
  2086. // X 轴
  2087. if (origin.y >= m && origin.y <= m + ch) {
  2088. ctx.beginPath();
  2089. ctx.moveTo(m, origin.y);
  2090. ctx.lineTo(m + cw, origin.y);
  2091. ctx.stroke();
  2092. // 箭头
  2093. ctx.beginPath();
  2094. ctx.moveTo(m + cw, origin.y);
  2095. ctx.lineTo(m + cw - 8, origin.y - 4);
  2096. ctx.lineTo(m + cw - 8, origin.y + 4);
  2097. ctx.fill();
  2098. }
  2099. // Y 轴
  2100. if (origin.x >= m && origin.x <= m + cw) {
  2101. ctx.beginPath();
  2102. ctx.moveTo(origin.x, m + ch);
  2103. ctx.lineTo(origin.x, m);
  2104. ctx.stroke();
  2105. // 箭头
  2106. ctx.beginPath();
  2107. ctx.moveTo(origin.x, m);
  2108. ctx.lineTo(origin.x - 4, m + 8);
  2109. ctx.lineTo(origin.x + 4, m + 8);
  2110. ctx.fill();
  2111. }
  2112. // 原点标签
  2113. if (origin.x >= m && origin.x <= m + cw && origin.y >= m && origin.y <= m + ch) {
  2114. ctx.textAlign = 'right';
  2115. ctx.fillText('O', origin.x - 6, origin.y + 14);
  2116. }
  2117. }
  2118. niceStep(range, target) {
  2119. const rough = range / target;
  2120. const exp = Math.pow(10, Math.floor(Math.log10(rough)));
  2121. const mant = rough / exp;
  2122. let nice = 1;
  2123. if (mant <= 1.5) nice = 1;
  2124. else if (mant <= 3.5) nice = 2;
  2125. else if (mant <= 7.5) nice = 5;
  2126. else nice = 10;
  2127. return nice * exp;
  2128. }
  2129. fmtNum(n) {
  2130. if (Math.abs(n) < 1e-10) return '0';
  2131. if (Math.abs(n) < 0.001 || Math.abs(n) >= 1e6) return n.toExponential(2);
  2132. if (Number.isInteger(n)) return n.toString();
  2133. const s = n.toFixed(4);
  2134. return s.replace(/0+$/, '').replace(/\.$/, '');
  2135. }
  2136. // ── 手势 ──────────────────────────────────────────
  2137. bindGestures() {
  2138. const canvas = this.canvas;
  2139. // 移除旧监听器防止泄露
  2140. if (this._touchHandlers) {
  2141. canvas.removeEventListener('touchstart', this._touchHandlers.start);
  2142. canvas.removeEventListener('touchmove', this._touchHandlers.move);
  2143. canvas.removeEventListener('touchend', this._touchHandlers.end);
  2144. }
  2145. let lastDist = 0, lastX = 0, lastY = 0, lastTap = 0, initialPinch = false;
  2146. const self = this;
  2147. const onStart = (e) => {
  2148. if (e.touches.length >= 2) {
  2149. const dx = e.touches[0].clientX - e.touches[1].clientX;
  2150. const dy = e.touches[0].clientY - e.touches[1].clientY;
  2151. lastDist = Math.sqrt(dx*dx + dy*dy);
  2152. initialPinch = true;
  2153. }
  2154. if (e.touches.length === 1 && !initialPinch) {
  2155. lastX = e.touches[0].clientX;
  2156. lastY = e.touches[0].clientY;
  2157. }
  2158. };
  2159. const onMove = (e) => {
  2160. if (e.touches.length >= 2 && initialPinch && lastDist > 0) {
  2161. const dx = e.touches[0].clientX - e.touches[1].clientX;
  2162. const dy = e.touches[0].clientY - e.touches[1].clientY;
  2163. const dist = Math.sqrt(dx*dx + dy*dy);
  2164. const scale = dist / lastDist;
  2165. if (scale > 0.3 && scale < 3) {
  2166. self.transform.sx = Math.max(0.1, Math.min(20, self.transform.sx / scale));
  2167. self.transform.sy = Math.max(0.1, Math.min(20, self.transform.sy / scale));
  2168. }
  2169. lastDist = dist;
  2170. self.draw();
  2171. e.preventDefault();
  2172. } else if (e.touches.length === 1 && !initialPinch) {
  2173. const dx = (e.touches[0].clientX - lastX) / canvas.clientWidth;
  2174. const dy = (e.touches[0].clientY - lastY) / canvas.clientHeight;
  2175. self.transform.ox -= dx / self.transform.sx;
  2176. self.transform.oy += dy / self.transform.sy;
  2177. lastX = e.touches[0].clientX;
  2178. lastY = e.touches[0].clientY;
  2179. self.draw();
  2180. e.preventDefault();
  2181. }
  2182. };
  2183. const onEnd = (e) => {
  2184. if (e.touches.length === 0) {
  2185. const now = Date.now();
  2186. if (initialPinch) {
  2187. initialPinch = false;
  2188. } else if (now - lastTap < 350) {
  2189. self.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
  2190. self.draw();
  2191. }
  2192. lastTap = now;
  2193. }
  2194. };
  2195. canvas.addEventListener('touchstart', onStart, { passive: true });
  2196. canvas.addEventListener('touchmove', onMove, { passive: false });
  2197. canvas.addEventListener('touchend', onEnd, { passive: true });
  2198. this._touchHandlers = { start: onStart, move: onMove, end: onEnd };
  2199. }
  2200. }