@co0ontty/wand 1.35.2 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -326,6 +326,36 @@
326
326
  miniKeyboardVisible: false,
327
327
  shortcutsExpanded: false,
328
328
  modifiers: { ctrl: false, alt: false, shift: false },
329
+ // ── 终端悬浮摇杆遥控器(手机端 PTY 遥控)状态 ──
330
+ // joystickPos 持久化球球位置 {right, bottom}(localStorage wand-ball-pos)
331
+ joystickPos: (function() {
332
+ try {
333
+ var saved = localStorage.getItem("wand-ball-pos");
334
+ if (!saved) return null;
335
+ var parsed = JSON.parse(saved);
336
+ return parsed && typeof parsed === "object" ? parsed : null;
337
+ } catch (e) {
338
+ return null;
339
+ }
340
+ })(),
341
+ joystickPinnedOpen: false, // 钉住面板是否展开(不持久化,切会话复位)
342
+ joystickRootEl: null, // 以下均为运行期句柄,teardown 复位
343
+ joystickRingEl: null,
344
+ joystickPanelEl: null,
345
+ joystickBackdropEl: null,
346
+ joystickBallEl: null,
347
+ joystickPointerId: null,
348
+ joystickGesture: null, // null|'pending'|'ring'|'move'
349
+ joystickPressStart: null, // {x, y, t}
350
+ joystickCenter: null, // 手势开始时球球中心,用于径向命中
351
+ joystickLongPressTimer: null,
352
+ joystickRepeatTimer: null,
353
+ joystickRepeatKey: null,
354
+ joystickHoverOuter: null, // 外圈当前高亮键(松手发送)
355
+ joystickLastHoverKey: null, // 上一次悬停的扇区键(用于切换震动反馈)
356
+ joystickMoveHandler: null,
357
+ joystickUpHandler: null,
358
+ joystickResizeHandler: null,
329
359
  fileSearchQuery: "",
330
360
  fileExplorerLoading: false,
331
361
  allFiles: [],
@@ -334,7 +364,7 @@
334
364
  fileExplorerTotal: 0,
335
365
  claudeHistory: [],
336
366
  claudeHistoryLoaded: false,
337
- claudeHistoryExpanded: true,
367
+ claudeHistoryExpanded: false,
338
368
  claudeHistoryExpandedDirs: {},
339
369
  archivedExpanded: false,
340
370
  sessionsManageMode: false,
@@ -1685,6 +1715,7 @@
1685
1715
  '<div class="sessions-list" id="sessions-list">' + renderSessionsListContent() + '</div>' +
1686
1716
  '</div>' +
1687
1717
  '</div>' +
1718
+ '<div id="sidebar-history-region" class="sidebar-history-region">' + renderClaudeHistoryRegion() + '</div>' +
1688
1719
  '<div class="sidebar-footer">' +
1689
1720
  '<button id="drawer-new-session-button" class="btn btn-primary btn-block"><span>+</span> 新会话</button>' +
1690
1721
  '<div class="sidebar-footer-actions">' +
@@ -3361,6 +3392,9 @@
3361
3392
  }
3362
3393
 
3363
3394
  function renderSessions() {
3395
+ // Claude history is no longer inlined here — it lives in its own
3396
+ // collapsible region between .sidebar-body and .sidebar-footer, so
3397
+ // the scrolling sessions list focuses on recent / archived sessions.
3364
3398
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
3365
3399
  var groups = [];
3366
3400
  groups.push(renderSessionManageBar());
@@ -3373,9 +3407,8 @@
3373
3407
  if (archivedSessions.length > 0) {
3374
3408
  groups.push(renderArchivedGroup(archivedSessions));
3375
3409
  }
3376
- groups.push(renderClaudeHistorySection());
3377
3410
  if (recentEntries.length === 0 && archivedSessions.length === 0) {
3378
- return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>' + renderClaudeHistorySection();
3411
+ return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>';
3379
3412
  }
3380
3413
  return groups.join("");
3381
3414
  }
@@ -3482,37 +3515,62 @@
3482
3515
  return html;
3483
3516
  }
3484
3517
 
3485
- function renderClaudeHistorySection() {
3486
- // Exclude recent 24h items from history section
3518
+ // Compute the items eligible for the history region (older than 24h —
3519
+ // the recent-24h ones already show in the recent group above).
3520
+ function getClaudeHistoryRegionItems() {
3487
3521
  var cutoff = Date.now() - 24 * 60 * 60 * 1000;
3488
- var visibleHistory = getVisibleClaudeHistorySessions().filter(function(s) {
3522
+ return getVisibleClaudeHistorySessions().filter(function(s) {
3489
3523
  return !s.timestamp || new Date(s.timestamp).getTime() <= cutoff;
3490
3524
  });
3491
- var chevron = state.claudeHistoryExpanded ? "&#9662;" : "&#9656;";
3492
- var countBadge = state.claudeHistoryLoaded && visibleHistory.length > 0
3493
- ? ' <span class="history-count">' + visibleHistory.length + '</span>'
3494
- : '';
3495
- var clearAllButton = state.claudeHistoryExpanded && state.claudeHistoryLoaded && visibleHistory.length > 0
3496
- ? '<button class="btn btn-danger btn-xs session-history-clear" data-action="clear-all-history" type="button">清空</button>'
3497
- : '';
3498
- var header = '<div class="session-group-title claude-history-toggle" id="claude-history-toggle">' +
3499
- '<span class="chevron">' + chevron + '</span> Claude 历史' + countBadge +
3500
- '</div>' + clearAllButton;
3525
+ }
3501
3526
 
3502
- if (!state.claudeHistoryExpanded) {
3503
- return '<section class="session-group">' + header + '</section>';
3527
+ // Render the docked Claude-history region that lives between
3528
+ // `.sidebar-body` and `.sidebar-footer`. Collapsed by default — only
3529
+ // shows a slim header ("历史消息" + count bubble). Expanded reveals the
3530
+ // grouped-by-cwd list inside a scroll cap.
3531
+ function renderClaudeHistoryRegion() {
3532
+ var visibleHistory = getClaudeHistoryRegionItems();
3533
+ var expanded = !!state.claudeHistoryExpanded;
3534
+ var loaded = !!state.claudeHistoryLoaded;
3535
+ var count = loaded ? visibleHistory.length : 0;
3536
+
3537
+ var badgeCls = "history-bubble";
3538
+ var badgeContent;
3539
+ if (!loaded) {
3540
+ badgeCls += " loading";
3541
+ badgeContent = "···";
3542
+ } else if (count === 0) {
3543
+ badgeCls += " empty";
3544
+ badgeContent = "0";
3545
+ } else {
3546
+ badgeContent = count > 999 ? "999+" : String(count);
3504
3547
  }
3548
+ var badge = '<span class="' + badgeCls + '">' + badgeContent + '</span>';
3549
+
3550
+ // Chevron rotates: collapsed → up (▲, suggests "expand upward"),
3551
+ // expanded → down (▼, suggests "collapse downward").
3552
+ var chevronSvg = '<svg class="sidebar-history-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg>';
3553
+
3554
+ var headerCls = "sidebar-history-header" + (expanded ? " expanded" : "");
3555
+ var header = '<button type="button" class="' + headerCls + '" id="claude-history-toggle" aria-expanded="' + expanded + '" aria-controls="sidebar-history-body" title="' + (expanded ? "收起历史消息" : "展开历史消息") + '">' +
3556
+ '<span class="sidebar-history-label">历史消息</span>' +
3557
+ '<span class="sidebar-history-right">' + badge + chevronSvg + '</span>' +
3558
+ '</button>';
3559
+
3560
+ var body = expanded
3561
+ ? '<div class="sidebar-history-body" id="sidebar-history-body">' + renderClaudeHistoryBodyContent(visibleHistory) + '</div>'
3562
+ : '';
3505
3563
 
3564
+ return header + body;
3565
+ }
3566
+
3567
+ function renderClaudeHistoryBodyContent(visibleHistory) {
3506
3568
  if (!state.claudeHistoryLoaded) {
3507
- return '<section class="session-group">' + header +
3508
- '<div class="claude-history-loading">扫描历史会话中…</div></section>';
3569
+ return '<div class="claude-history-loading">扫描历史会话中…</div>';
3509
3570
  }
3510
-
3511
3571
  if (visibleHistory.length === 0) {
3512
- return '<section class="session-group">' + header +
3513
- '<div class="claude-history-loading">没有更早的 Claude 历史会话</div></section>';
3572
+ return '<div class="claude-history-loading">没有更早的 Claude 历史会话</div>';
3514
3573
  }
3515
-
3516
3574
  var groups = {};
3517
3575
  var groupOrder = [];
3518
3576
  visibleHistory.forEach(function(s) {
@@ -3522,18 +3580,27 @@
3522
3580
  }
3523
3581
  groups[s.cwd].push(s);
3524
3582
  });
3525
-
3526
- var html = '';
3583
+ var toolbar = '<div class="sidebar-history-toolbar">' +
3584
+ '<button class="btn btn-ghost btn-xs sidebar-history-clear" data-action="clear-all-history" type="button">清空全部</button>' +
3585
+ '</div>';
3586
+ var listHtml = '';
3527
3587
  groupOrder.forEach(function(cwd) {
3528
3588
  var cwdShort = cwd.split("/").filter(Boolean).slice(-3).join("/");
3529
3589
  var isDirExpanded = !!state.claudeHistoryExpandedDirs[cwd];
3530
- html += renderClaudeHistoryDirectoryHeader(cwd, cwdShort, groups[cwd].length, isDirExpanded);
3590
+ listHtml += renderClaudeHistoryDirectoryHeader(cwd, cwdShort, groups[cwd].length, isDirExpanded);
3531
3591
  if (isDirExpanded) {
3532
- html += groups[cwd].map(function(session) { return renderClaudeHistoryItem(session, "history"); }).join("");
3592
+ listHtml += groups[cwd].map(function(session) { return renderClaudeHistoryItem(session, "history"); }).join("");
3533
3593
  }
3534
3594
  });
3595
+ return toolbar + '<div class="sidebar-history-scroll">' + listHtml + '</div>';
3596
+ }
3535
3597
 
3536
- return '<section class="session-group">' + header + html + '</section>';
3598
+ // Re-render only the docked history region in place. Called by
3599
+ // updateSessionsList() so existing callers (load complete, delete, etc.)
3600
+ // keep working without changes.
3601
+ function updateClaudeHistoryRegion() {
3602
+ var region = document.getElementById("sidebar-history-region");
3603
+ if (region) region.innerHTML = renderClaudeHistoryRegion();
3537
3604
  }
3538
3605
 
3539
3606
  function getVisibleClaudeHistorySessions() {
@@ -5858,6 +5925,15 @@
5858
5925
  sessionsList.addEventListener("mouseout", handleCollapsedTileLeave);
5859
5926
  initSwipeToDelete(sessionsList);
5860
5927
  }
5928
+ // The docked history region lives outside #sessions-list now, but it
5929
+ // still wants the same delegated handlers (toggle button, directory
5930
+ // expand/collapse, history-item clicks, clear-all, etc.). Reuse the
5931
+ // same callbacks so behavior stays identical.
5932
+ var historyRegion = document.getElementById("sidebar-history-region");
5933
+ if (historyRegion) {
5934
+ historyRegion.addEventListener("click", handleSessionItemClick);
5935
+ historyRegion.addEventListener("keydown", handleSessionItemKeydown);
5936
+ }
5861
5937
  window.addEventListener("scroll", hideCollapsedTileBubble, true);
5862
5938
  window.addEventListener("resize", hideCollapsedTileBubble);
5863
5939
 
@@ -5942,7 +6018,8 @@
5942
6018
  state.selectedId = null;
5943
6019
  persistSelectedId();
5944
6020
  resetChatRenderCache();
5945
- closeSessionsDrawer();
6021
+ // 回到首页是导航语义,不是「收侧栏」。桌面常驻栏保留;手机只把 overlay 收掉。
6022
+ dismissDrawerIfOverlay();
5946
6023
  render();
5947
6024
  });
5948
6025
  var refreshBtn = document.getElementById("sidebar-refresh-btn");
@@ -6188,7 +6265,8 @@
6188
6265
  if (autoApproveToggle) autoApproveToggle.addEventListener("click", toggleAutoApprove);
6189
6266
  var sendBtn = document.getElementById("send-input-button");
6190
6267
  if (sendBtn) sendBtn.addEventListener("click", function() {
6191
- closeSessionsDrawer();
6268
+ // 与 input focus 同理:手机 drawer 盖在上面才收起,桌面常驻栏保持原状。
6269
+ dismissDrawerIfOverlay();
6192
6270
  sendOrStart();
6193
6271
  });
6194
6272
  var stopBtn = document.getElementById("stop-button");
@@ -6241,8 +6319,10 @@
6241
6319
  if (state.terminalInteractive) handleInteractiveTextInput(inputBox);
6242
6320
  });
6243
6321
  inputBox.addEventListener("focus", function() {
6244
- // Close drawer when user focuses input to avoid backdrop blocking clicks
6245
- closeSessionsDrawer();
6322
+ // 只在手机 drawer 真的盖在输入区上面时才收起,避免 backdrop 挡点击。
6323
+ // 桌面 pinned/窄条形态下 drawer 是常驻并列布局,不会挡输入,调
6324
+ // closeSessionsDrawer 会把 sidebarPinned 一起清掉、侧栏整个不见。
6325
+ dismissDrawerIfOverlay();
6246
6326
  handleInputBoxFocus({ target: inputBox });
6247
6327
  });
6248
6328
  inputBox.addEventListener("blur", handleInputBoxBlur);
@@ -6960,9 +7040,11 @@
6960
7040
  } else {
6961
7041
  selectSession(sessionId);
6962
7042
  }
6963
- if (!state.sidebarPinned || isMobileLayout()) {
6964
- closeSessionsDrawer();
6965
- }
7043
+ // 桌面常驻栏与窄条形态都保留;只在手机端真的有 overlay drawer 时才收。
7044
+ // (旧条件 !sidebarPinned || isMobileLayout() 在桌面 not-pinned 状态下也会
7045
+ // 调 closeSessionsDrawer,靠内部 early-return 才不至于出错——含义不清晰,
7046
+ // 统一走 dismissDrawerIfOverlay 反过来表达"只收 overlay 不撤常驻"。)
7047
+ dismissDrawerIfOverlay();
6966
7048
  }
6967
7049
 
6968
7050
  function handleSessionItemClick(event) {
@@ -7097,7 +7179,8 @@
7097
7179
  state.drafts[data.id] = "";
7098
7180
  loadSessions().then(function() {
7099
7181
  selectSession(data.id);
7100
- closeSessionsDrawer();
7182
+ // 桌面常驻/窄条形态不要撤掉,只把手机端 overlay 收掉。
7183
+ dismissDrawerIfOverlay();
7101
7184
  });
7102
7185
  }
7103
7186
  });
@@ -7131,7 +7214,8 @@
7131
7214
  state.drafts[data.id] = "";
7132
7215
  loadSessions().then(function() {
7133
7216
  selectSession(data.id);
7134
- closeSessionsDrawer();
7217
+ // 桌面常驻/窄条形态不要撤掉,只把手机端 overlay 收掉。
7218
+ dismissDrawerIfOverlay();
7135
7219
  });
7136
7220
  }
7137
7221
  });
@@ -8197,6 +8281,7 @@
8197
8281
  container.addEventListener("click", state.terminalClickHandler);
8198
8282
  updateTerminalJumpToBottomButton();
8199
8283
  initTerminalResizeHandle();
8284
+ initTerminalJoystick();
8200
8285
  observeTerminalResize();
8201
8286
  startTerminalHealthCheck();
8202
8287
  // Container may have been hidden / zero-width at construction
@@ -9225,6 +9310,10 @@
9225
9310
  var countEl = document.getElementById("session-count");
9226
9311
  if (listEl) listEl.innerHTML = renderSessionsListContent();
9227
9312
  if (countEl) countEl.textContent = String(state.sessions.length);
9313
+ // The docked history region lives outside #sessions-list — refresh it
9314
+ // too so callers that mutate state.claudeHistory (load complete,
9315
+ // delete, clear) don't need to know about it.
9316
+ updateClaudeHistoryRegion();
9228
9317
  if (typeof hideCollapsedTileBubble === "function") hideCollapsedTileBubble();
9229
9318
  updateShellChrome();
9230
9319
  // Re-render cross-session queue (container may have been destroyed by DOM rebuild)
@@ -9519,6 +9608,19 @@
9519
9608
  updateLayoutState();
9520
9609
  }
9521
9610
 
9611
+ // 把"浮在内容上的 drawer/backdrop"关掉,但保留桌面常驻栏与窄条形态。
9612
+ // 用法:从 input focus / send 按钮 / 选中会话 / 新建会话回调里调,这些场
9613
+ // 景只想避免遮罩挡住内容,并不想撤掉用户主动开启的常驻侧栏。
9614
+ //
9615
+ // 直接调 closeSessionsDrawer() 会在桌面把 state.sidebarPinned 置 false,
9616
+ // 进而让 .pinned/.collapsed 这两个类一起脱落,窄条整体消失 —— 这是
9617
+ // sidebar-collapsed-tile 点击后侧栏整个不见的根因。
9618
+ function dismissDrawerIfOverlay() {
9619
+ if (isMobileLayout() && state.sessionsDrawerOpen) {
9620
+ closeSessionsDrawer();
9621
+ }
9622
+ }
9623
+
9522
9624
  // 桌面 padding-left transition 结束后重新拟合终端尺寸。
9523
9625
  // 抽出来给 toggleSessionsDrawer / closeSessionsDrawer / toggleSidebarCollapsed 复用。
9524
9626
  function scheduleTerminalRefitAfterPaddingTransition() {
@@ -11336,7 +11438,9 @@
11336
11438
  .then(function(data) {
11337
11439
  saveWorkingDir(cwd);
11338
11440
  closeSessionModal();
11339
- closeSessionsDrawer();
11441
+ // 桌面常驻栏要保留:用户刚建完会话,希望左侧栏继续看到列表里的新条目,
11442
+ // 不能因为模态关闭顺手把 sidebarPinned 抹掉让侧栏整体消失。
11443
+ dismissDrawerIfOverlay();
11340
11444
  return data;
11341
11445
  })
11342
11446
  .then(function() { focusInputBox(true); })
@@ -11382,7 +11486,8 @@
11382
11486
  state.drafts[data.id] = "";
11383
11487
  resetChatRenderCache();
11384
11488
  closeSessionModal();
11385
- closeSessionsDrawer();
11489
+ // 同 structured 路径:模态关闭后只收手机端的 overlay,保留桌面常驻侧栏。
11490
+ dismissDrawerIfOverlay();
11386
11491
  return refreshAll();
11387
11492
  })
11388
11493
  .then(function() {
@@ -13610,6 +13715,48 @@
13610
13715
  "_": 31
13611
13716
  };
13612
13717
 
13718
+ // ── 终端悬浮摇杆遥控器常量与布局表 ──
13719
+ var JOYSTICK_LONG_PRESS_MS = 400; // 按住不动多久进入移动模式
13720
+ var JOYSTICK_MOVE_THRESHOLD = 10; // px:区分"拖动选键"与"静止长按"
13721
+ var JOYSTICK_TAP_THRESHOLD = 8; // px:快速点击的最大位移
13722
+ var JOYSTICK_REPEAT_MS = 130; // 内圈方向键连发间隔
13723
+ var JOYSTICK_R0 = 24; // 扇形中心空洞 = 死区半径
13724
+ var JOYSTICK_R1 = 60; // 内圈(方向)/外圈(功能)分界半径
13725
+ var JOYSTICK_R2 = 104; // 外圈外缘半径
13726
+ var JOYSTICK_DEADZONE_R = 24; // 命中死区(= R0)
13727
+ var JOYSTICK_RING_SPLIT_R = 60; // 命中分界(= R1):< 内圈方向,>= 外圈功能
13728
+ var JOYSTICK_MOVE_OUT_R = 140; // 拖出此半径(超出外圈区域)→ 切"正在移动"
13729
+ var JOYSTICK_BALL_SIZE = 52; // 球球直径(与 CSS 一致)
13730
+ var JOYSTICK_EDGE_MARGIN = 8; // 球球钳进视口的留白
13731
+ var JOYSTICK_RING_RADIUS = JOYSTICK_R2 + 8; // 环整体半径(含标签外延),用于把圆心钳进视口
13732
+ var JOYSTICK_RING_VIEW_PAD = 6; // 环外缘与视口边的最小留白
13733
+ var JOYSTICK_SECTOR_GAP_DEG = 2; // 外圈扇区之间的角度细缝(°),读成独立按钮
13734
+ // 内圈 4 方向:i=0 上、1 右、2 下、3 左(与渲染角 -90° 起顺时针一致)
13735
+ var JOYSTICK_INNER_KEYS = [
13736
+ { key: "up", label: "↑" },
13737
+ { key: "right", label: "→" },
13738
+ { key: "down", label: "↓" },
13739
+ { key: "left", label: "←" }
13740
+ ];
13741
+ // 外圈 8 功能:i=0 正上方起顺时针,与八分扇区 idx 对应
13742
+ var JOYSTICK_OUTER_KEYS = [
13743
+ { key: "enter", label: "Enter" },
13744
+ { key: "escape", label: "Esc" },
13745
+ { key: "tab", label: "Tab" },
13746
+ { key: "shift_tab", label: "Shift+Tab" },
13747
+ { key: "ctrl_c", label: "Ctrl+C" },
13748
+ { key: "ctrl_z", label: "Ctrl+Z" },
13749
+ { key: "ctrl_d", label: "Ctrl+D" },
13750
+ { key: "ctrl_l", label: "Ctrl+L" }
13751
+ ];
13752
+ // 钉住面板四角翻页键
13753
+ var JOYSTICK_CORNER_KEYS = [
13754
+ { key: "pageup", label: "PgUp" },
13755
+ { key: "home", label: "Home" },
13756
+ { key: "pagedown", label: "PgDn" },
13757
+ { key: "end", label: "End" }
13758
+ ];
13759
+
13613
13760
  var ignoredInteractiveTargetIds = new Set([
13614
13761
  "mini-keyboard-fab",
13615
13762
  "mini-keyboard-toggle",
@@ -13783,6 +13930,7 @@
13783
13930
  }
13784
13931
  var container = document.getElementById("output");
13785
13932
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
13933
+ updateJoystickVisibility();
13786
13934
  }
13787
13935
 
13788
13936
  // COPY-2/COPY-4: 是否存在落在终端输出区(#output)内的活动文本选区。用于:
@@ -14411,7 +14559,8 @@
14411
14559
  persistSelectedId();
14412
14560
  state.drafts[data.id] = "";
14413
14561
  activateSession(data).then(function() {
14414
- closeSessionsDrawer();
14562
+ // 桌面常驻/窄条形态不要撤掉,仅手机端 overlay 需要收。
14563
+ dismissDrawerIfOverlay();
14415
14564
  });
14416
14565
  }
14417
14566
  })
@@ -15450,6 +15599,588 @@
15450
15599
  document.addEventListener("touchend", state.resizeTouchEnd);
15451
15600
  }
15452
15601
 
15602
+ // ====== 终端悬浮摇杆遥控器(手机端 PTY 遥控) ======
15603
+ // 纯前端覆盖层:fixed 挂到 body,绕开 #output 的 overflow:hidden 裁切。
15604
+ // 用 Pointer Events 统一鼠标/触摸;球球 touch-action:none 让 preventDefault 稳定。
15605
+ // 不改动终端背景的 touch/scroll/wheel —— 单指空白处仍是原生滚动看历史。
15606
+
15607
+ function isJoystickAvailable() {
15608
+ // 触屏与桌面网页端都显示(球球用 Pointer Events,鼠标拖拽同样可用)
15609
+ if (state.currentView !== "terminal") return false;
15610
+ var session = getSelectedSession();
15611
+ if (!session) return false;
15612
+ if (isStructuredSession(session)) return false;
15613
+ return true;
15614
+ }
15615
+
15616
+ function clampJoystickPos(pos) {
15617
+ var maxRight = Math.max(JOYSTICK_EDGE_MARGIN, window.innerWidth - JOYSTICK_BALL_SIZE - JOYSTICK_EDGE_MARGIN);
15618
+ var maxBottom = Math.max(JOYSTICK_EDGE_MARGIN, window.innerHeight - JOYSTICK_BALL_SIZE - JOYSTICK_EDGE_MARGIN);
15619
+ return {
15620
+ right: Math.min(Math.max(JOYSTICK_EDGE_MARGIN, pos.right), maxRight),
15621
+ bottom: Math.min(Math.max(JOYSTICK_EDGE_MARGIN, pos.bottom), maxBottom)
15622
+ };
15623
+ }
15624
+
15625
+ function applyJoystickPosition() {
15626
+ if (!state.joystickBallEl) return;
15627
+ var pos = clampJoystickPos(state.joystickPos || { right: 18, bottom: 96 });
15628
+ state.joystickBallEl.style.right = pos.right + "px";
15629
+ state.joystickBallEl.style.bottom = pos.bottom + "px";
15630
+ }
15631
+
15632
+ function saveJoystickPosition(right, bottom) {
15633
+ var pos = clampJoystickPos({ right: right, bottom: bottom });
15634
+ state.joystickPos = pos;
15635
+ try {
15636
+ localStorage.setItem("wand-ball-pos", JSON.stringify(pos));
15637
+ } catch (e) {
15638
+ // Ignore localStorage errors
15639
+ }
15640
+ }
15641
+
15642
+ function renderJoystickPanel() {
15643
+ function keyBtn(key, label, cls) {
15644
+ return '<button type="button" class="wjp-key' + (cls ? " " + cls : "") +
15645
+ '" data-key="' + key + '">' + label + "</button>";
15646
+ }
15647
+ var dpad =
15648
+ '<div class="wjp-dpad">' +
15649
+ '<div class="wjp-dpad-row">' + keyBtn("up", "↑", "wjp-dir") + "</div>" +
15650
+ '<div class="wjp-dpad-row">' +
15651
+ keyBtn("left", "←", "wjp-dir") + keyBtn("down", "↓", "wjp-dir") + keyBtn("right", "→", "wjp-dir") +
15652
+ "</div>" +
15653
+ "</div>";
15654
+ var fnRow = "";
15655
+ var i;
15656
+ for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15657
+ fnRow += keyBtn(JOYSTICK_OUTER_KEYS[i].key, JOYSTICK_OUTER_KEYS[i].label, "");
15658
+ }
15659
+ var cornerRow = "";
15660
+ for (i = 0; i < JOYSTICK_CORNER_KEYS.length; i++) {
15661
+ cornerRow += keyBtn(JOYSTICK_CORNER_KEYS[i].key, JOYSTICK_CORNER_KEYS[i].label, "");
15662
+ }
15663
+ var modRow = keyBtn("ctrl", "Ctrl", "wjp-mod") + keyBtn("alt", "Alt", "wjp-mod");
15664
+ return '<div class="wjp-title">遥控面板</div>' +
15665
+ dpad +
15666
+ '<div class="wjp-grid wjp-fnkeys">' + fnRow + "</div>" +
15667
+ '<div class="wjp-grid wjp-corners">' + cornerRow + "</div>" +
15668
+ '<div class="wjp-grid wjp-mods">' + modRow + "</div>";
15669
+ }
15670
+
15671
+ function joystickPolar(r, deg) {
15672
+ var a = deg * Math.PI / 180;
15673
+ return { x: +(r * Math.cos(a)).toFixed(2), y: +(r * Math.sin(a)).toFixed(2) };
15674
+ }
15675
+
15676
+ // 环形扇区(annular sector)路径:外弧顺时针、内弧逆时针闭合
15677
+ function joystickSectorPath(rIn, rOut, startDeg, endDeg) {
15678
+ var a = joystickPolar(rOut, startDeg), b = joystickPolar(rOut, endDeg);
15679
+ var c = joystickPolar(rIn, endDeg), d = joystickPolar(rIn, startDeg);
15680
+ return "M" + a.x + " " + a.y +
15681
+ "A" + rOut + " " + rOut + " 0 0 1 " + b.x + " " + b.y +
15682
+ "L" + c.x + " " + c.y +
15683
+ "A" + rIn + " " + rIn + " 0 0 0 " + d.x + " " + d.y + "Z";
15684
+ }
15685
+
15686
+ // 标签渲染:组合键(含 "+")拆成两行,前缀在上、+键在下,省横向空间更清晰。
15687
+ function joystickLabelMarkup(label, x, y) {
15688
+ var plus = label.indexOf("+");
15689
+ if (plus > 0) {
15690
+ var top = label.slice(0, plus);
15691
+ var bot = "+" + label.slice(plus + 1);
15692
+ return '<text class="wjr-2line" x="' + x + '" y="' + y + '">' +
15693
+ '<tspan x="' + x + '" dy="-0.42em">' + top + '</tspan>' +
15694
+ '<tspan x="' + x + '" dy="0.95em">' + bot + '</tspan></text>';
15695
+ }
15696
+ return '<text x="' + x + '" y="' + y + '">' + label + "</text>";
15697
+ }
15698
+
15699
+ // 构建两圈扇形 pie 菜单 SVG:内圈 4×90° 方向、外圈 8×45° 功能 + 中心选中提示。
15700
+ // 扇区角度与 joystickHitTest 完全对应(正上=0、顺时针)。
15701
+ function buildJoystickRingSvg() {
15702
+ var size = (JOYSTICK_R2 + 6) * 2;
15703
+ var half = size / 2;
15704
+ var gap = JOYSTICK_SECTOR_GAP_DEG / 2; // 每个扇区起止各内缩半个细缝
15705
+ var svg = '<svg class="wjr-svg" width="' + size + '" height="' + size +
15706
+ '" viewBox="' + (-half) + " " + (-half) + " " + size + " " + size + '">';
15707
+ // 底盘:所有扇区之下的一整块玻璃圆盘,细缝/外缘透出它作分隔与外圈光环
15708
+ svg += '<circle class="wjr-base" cx="0" cy="0" r="' + (JOYSTICK_R2 + 4) + '"/>';
15709
+ var i, k, center, lp;
15710
+ for (i = 0; i < JOYSTICK_INNER_KEYS.length; i++) {
15711
+ k = JOYSTICK_INNER_KEYS[i];
15712
+ center = -90 + i * 90;
15713
+ lp = joystickPolar((JOYSTICK_R0 + JOYSTICK_R1) / 2, center);
15714
+ svg += '<g class="wjr-sector wjr-inner" data-key="' + k.key + '">' +
15715
+ '<path d="' + joystickSectorPath(JOYSTICK_R0, JOYSTICK_R1, center - 45 + gap, center + 45 - gap) + '"/>' +
15716
+ joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
15717
+ }
15718
+ for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15719
+ k = JOYSTICK_OUTER_KEYS[i];
15720
+ center = -90 + i * 45;
15721
+ lp = joystickPolar((JOYSTICK_R1 + JOYSTICK_R2) / 2, center);
15722
+ svg += '<g class="wjr-sector wjr-outer" data-key="' + k.key + '">' +
15723
+ '<path d="' + joystickSectorPath(JOYSTICK_R1, JOYSTICK_R2, center - 22.5 + gap, center + 22.5 - gap) + '"/>' +
15724
+ joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
15725
+ }
15726
+ svg += '<circle class="wjr-hub" cx="0" cy="0" r="' + (JOYSTICK_R0 - 1) + '"/>';
15727
+ svg += '<text class="wjr-hub-label" x="0" y="0"></text>';
15728
+ return svg + "</svg>";
15729
+ }
15730
+
15731
+ function joystickLabelForKey(key) {
15732
+ var i;
15733
+ for (i = 0; i < JOYSTICK_INNER_KEYS.length; i++) {
15734
+ if (JOYSTICK_INNER_KEYS[i].key === key) return JOYSTICK_INNER_KEYS[i].label;
15735
+ }
15736
+ for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15737
+ if (JOYSTICK_OUTER_KEYS[i].key === key) return JOYSTICK_OUTER_KEYS[i].label;
15738
+ }
15739
+ return "";
15740
+ }
15741
+
15742
+ function setJoystickCenterLabel(text) {
15743
+ if (!state.joystickRingEl) return;
15744
+ var el = state.joystickRingEl.querySelector(".wjr-hub-label");
15745
+ if (el) el.textContent = text || "";
15746
+ }
15747
+
15748
+ function joystickHaptic(ms) {
15749
+ try { if (navigator.vibrate) navigator.vibrate(ms); } catch (e) {}
15750
+ }
15751
+
15752
+ function initTerminalJoystick() {
15753
+ if (state.joystickRootEl) return; // 已存在不重复建(触屏/桌面均构建)
15754
+
15755
+ var root = document.createElement("div");
15756
+ root.className = "wand-joystick-root";
15757
+
15758
+ var backdrop = document.createElement("div");
15759
+ backdrop.className = "wand-joystick-backdrop";
15760
+ root.appendChild(backdrop);
15761
+
15762
+ // 环形菜单容器(圆心运行期对齐球球中心)—— 扇形 pie 菜单(SVG,带文字 + 中心提示)
15763
+ var ring = document.createElement("div");
15764
+ ring.className = "wand-joystick-ring";
15765
+ ring.innerHTML = buildJoystickRingSvg();
15766
+ root.appendChild(ring);
15767
+
15768
+ // 钉住面板
15769
+ var panel = document.createElement("div");
15770
+ panel.className = "wand-joystick-panel";
15771
+ panel.innerHTML = renderJoystickPanel();
15772
+ panel.addEventListener("click", onJoystickPanelClick);
15773
+ root.appendChild(panel);
15774
+
15775
+ // 球球本体
15776
+ var ball = document.createElement("div");
15777
+ ball.className = "wand-joystick-ball";
15778
+ ball.setAttribute("role", "button");
15779
+ ball.setAttribute("aria-label", "终端摇杆遥控");
15780
+ ball.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" ' +
15781
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
15782
+ '<circle cx="12" cy="12" r="3"/><path d="M12 5V3M12 21v-2M5 12H3M21 12h-2"/></svg>';
15783
+ root.appendChild(ball);
15784
+
15785
+ document.body.appendChild(root);
15786
+
15787
+ state.joystickRootEl = root;
15788
+ state.joystickBackdropEl = backdrop;
15789
+ state.joystickRingEl = ring;
15790
+ state.joystickPanelEl = panel;
15791
+ state.joystickBallEl = ball;
15792
+
15793
+ applyJoystickPosition();
15794
+
15795
+ ball.addEventListener("pointerdown", onJoystickPointerDown);
15796
+ backdrop.addEventListener("pointerdown", function(e) {
15797
+ // 钉住面板开着且无进行中手势时,点遮罩收起面板
15798
+ if (state.joystickPinnedOpen && state.joystickGesture == null) {
15799
+ e.preventDefault();
15800
+ closeJoystickPanel();
15801
+ }
15802
+ });
15803
+
15804
+ // 旋转/窗口尺寸变化时重新钳制球球位置
15805
+ state.joystickResizeHandler = function() { applyJoystickPosition(); };
15806
+ window.addEventListener("resize", state.joystickResizeHandler);
15807
+ window.addEventListener("orientationchange", state.joystickResizeHandler);
15808
+
15809
+ updateJoystickVisibility();
15810
+ }
15811
+
15812
+ function getJoystickCenter() {
15813
+ if (!state.joystickBallEl) return { x: 0, y: 0 };
15814
+ var r = state.joystickBallEl.getBoundingClientRect();
15815
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
15816
+ }
15817
+
15818
+ function onJoystickPointerDown(e) {
15819
+ if (!isJoystickAvailable()) return;
15820
+ if (state.joystickPointerId !== null) return; // 已有手势在进行
15821
+ e.preventDefault();
15822
+ e.stopPropagation();
15823
+ state.joystickPointerId = e.pointerId;
15824
+ state.joystickPressStart = { x: e.clientX, y: e.clientY, t: Date.now() };
15825
+ state.joystickGesture = "pending";
15826
+ state.joystickHoverOuter = null;
15827
+ state.joystickCenter = getJoystickCenter();
15828
+ try { state.joystickBallEl.setPointerCapture(e.pointerId); } catch (err) {}
15829
+ // 起长按定时器:不动到 400ms → 移动模式
15830
+ state.joystickLongPressTimer = setTimeout(function() {
15831
+ if (state.joystickGesture === "pending") enterJoystickMoveMode();
15832
+ }, JOYSTICK_LONG_PRESS_MS);
15833
+ state.joystickMoveHandler = onJoystickPointerMove;
15834
+ state.joystickUpHandler = onJoystickPointerUp;
15835
+ document.addEventListener("pointermove", state.joystickMoveHandler);
15836
+ document.addEventListener("pointerup", state.joystickUpHandler);
15837
+ document.addEventListener("pointercancel", state.joystickUpHandler);
15838
+ }
15839
+
15840
+ function enterJoystickMoveMode() {
15841
+ state.joystickGesture = "move";
15842
+ if (state.joystickPinnedOpen) closeJoystickPanel();
15843
+ if (state.joystickBallEl) state.joystickBallEl.classList.add("dragging");
15844
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.add("active");
15845
+ }
15846
+
15847
+ function moveJoystickBallTo(clientX, clientY) {
15848
+ if (!state.joystickBallEl) return;
15849
+ var pos = clampJoystickPos({
15850
+ right: window.innerWidth - clientX - JOYSTICK_BALL_SIZE / 2,
15851
+ bottom: window.innerHeight - clientY - JOYSTICK_BALL_SIZE / 2
15852
+ });
15853
+ state.joystickBallEl.style.right = pos.right + "px";
15854
+ state.joystickBallEl.style.bottom = pos.bottom + "px";
15855
+ }
15856
+
15857
+ // 环形手势里把手指往外拖出外圈区域时调用:收起环,切到"正在移动"状态,
15858
+ // 球球立刻挪到手指下,之后跟手慢慢移动,松手保存位置。
15859
+ function switchJoystickToMoveMode(e) {
15860
+ stopJoystickRepeat();
15861
+ state.joystickHoverOuter = null;
15862
+ closeJoystickRing();
15863
+ state.joystickGesture = "move";
15864
+ if (state.joystickBallEl) state.joystickBallEl.classList.add("dragging");
15865
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.add("active");
15866
+ joystickHaptic(18);
15867
+ moveJoystickBallTo(e.clientX, e.clientY);
15868
+ }
15869
+
15870
+ function onJoystickPointerMove(e) {
15871
+ if (e.pointerId !== state.joystickPointerId) return;
15872
+ if (!state.joystickBallEl) return;
15873
+ e.preventDefault();
15874
+ var dxStart = e.clientX - state.joystickPressStart.x;
15875
+ var dyStart = e.clientY - state.joystickPressStart.y;
15876
+ if (state.joystickGesture === "pending") {
15877
+ if (Math.sqrt(dxStart * dxStart + dyStart * dyStart) > JOYSTICK_MOVE_THRESHOLD) {
15878
+ // 先动 → 选键手势
15879
+ if (state.joystickLongPressTimer) {
15880
+ clearTimeout(state.joystickLongPressTimer);
15881
+ state.joystickLongPressTimer = null;
15882
+ }
15883
+ if (state.joystickPinnedOpen) closeJoystickPanel();
15884
+ state.joystickGesture = "ring";
15885
+ state.joystickCenter = getJoystickCenter();
15886
+ openJoystickRing();
15887
+ } else {
15888
+ return;
15889
+ }
15890
+ }
15891
+ if (state.joystickGesture === "ring") {
15892
+ var c = state.joystickCenter || getJoystickCenter();
15893
+ var rdx = e.clientX - c.x;
15894
+ var rdy = e.clientY - c.y;
15895
+ // 往外拖超出外圈区域 → 切到"正在移动"状态,球球开始跟手
15896
+ if (Math.sqrt(rdx * rdx + rdy * rdy) > JOYSTICK_MOVE_OUT_R) {
15897
+ switchJoystickToMoveMode(e);
15898
+ return;
15899
+ }
15900
+ applyJoystickRingHit(joystickHitTest(rdx, rdy));
15901
+ return;
15902
+ }
15903
+ if (state.joystickGesture === "move") {
15904
+ moveJoystickBallTo(e.clientX, e.clientY);
15905
+ return;
15906
+ }
15907
+ }
15908
+
15909
+ function onJoystickPointerUp(e) {
15910
+ if (e.pointerId !== state.joystickPointerId) return;
15911
+ if (state.joystickLongPressTimer) {
15912
+ clearTimeout(state.joystickLongPressTimer);
15913
+ state.joystickLongPressTimer = null;
15914
+ }
15915
+ var gesture = state.joystickGesture;
15916
+ if (gesture === "ring") {
15917
+ stopJoystickRepeat();
15918
+ if (state.joystickHoverOuter) {
15919
+ joystickHaptic(18);
15920
+ sendJoystickKey(state.joystickHoverOuter);
15921
+ }
15922
+ } else if (gesture === "pending") {
15923
+ var dx = e.clientX - state.joystickPressStart.x;
15924
+ var dy = e.clientY - state.joystickPressStart.y;
15925
+ if (Math.sqrt(dx * dx + dy * dy) <= JOYSTICK_TAP_THRESHOLD) toggleJoystickPanel();
15926
+ } else if (gesture === "move") {
15927
+ var r = state.joystickBallEl ? state.joystickBallEl.getBoundingClientRect() : null;
15928
+ if (r) saveJoystickPosition(window.innerWidth - r.right, window.innerHeight - r.bottom);
15929
+ }
15930
+ endJoystickGesture();
15931
+ }
15932
+
15933
+ function endJoystickGesture() {
15934
+ stopJoystickRepeat();
15935
+ if (state.joystickLongPressTimer) {
15936
+ clearTimeout(state.joystickLongPressTimer);
15937
+ state.joystickLongPressTimer = null;
15938
+ }
15939
+ if (state.joystickBallEl && state.joystickPointerId !== null) {
15940
+ try { state.joystickBallEl.releasePointerCapture(state.joystickPointerId); } catch (err) {}
15941
+ }
15942
+ if (state.joystickMoveHandler) {
15943
+ document.removeEventListener("pointermove", state.joystickMoveHandler);
15944
+ state.joystickMoveHandler = null;
15945
+ }
15946
+ if (state.joystickUpHandler) {
15947
+ document.removeEventListener("pointerup", state.joystickUpHandler);
15948
+ document.removeEventListener("pointercancel", state.joystickUpHandler);
15949
+ state.joystickUpHandler = null;
15950
+ }
15951
+ closeJoystickRing();
15952
+ if (state.joystickBallEl) state.joystickBallEl.classList.remove("dragging");
15953
+ // 钉住面板若仍开着则保留遮罩,否则移除
15954
+ if (state.joystickBackdropEl && !state.joystickPinnedOpen) {
15955
+ state.joystickBackdropEl.classList.remove("active");
15956
+ }
15957
+ state.joystickPointerId = null;
15958
+ state.joystickGesture = null;
15959
+ state.joystickHoverOuter = null;
15960
+ state.joystickLastHoverKey = null;
15961
+ state.joystickPressStart = null;
15962
+ state.joystickCenter = null;
15963
+ }
15964
+
15965
+ function joystickHitTest(dx, dy) {
15966
+ var r = Math.sqrt(dx * dx + dy * dy);
15967
+ if (r < JOYSTICK_DEADZONE_R) return { zone: "dead", key: null };
15968
+ if (r < JOYSTICK_RING_SPLIT_R) {
15969
+ // 内圈:主轴象限(往上滑 dy<0 = up)
15970
+ if (Math.abs(dy) >= Math.abs(dx)) return { zone: "inner", key: dy < 0 ? "up" : "down" };
15971
+ return { zone: "inner", key: dx < 0 ? "left" : "right" };
15972
+ }
15973
+ // 外圈:8 等分扇区,正上方为 0,顺时针递增;+π/8 让扇区中心对准按钮
15974
+ var ang = Math.atan2(dx, -dy);
15975
+ if (ang < 0) ang += Math.PI * 2;
15976
+ var idx = Math.floor((ang + Math.PI / 8) / (Math.PI / 4)) % 8;
15977
+ return { zone: "outer", key: JOYSTICK_OUTER_KEYS[idx].key };
15978
+ }
15979
+
15980
+ function applyJoystickRingHit(hit) {
15981
+ if (!state.joystickRingEl) return;
15982
+ var key = hit.zone === "dead" ? null : hit.key;
15983
+ if (key !== state.joystickLastHoverKey) { // 切换扇区 → 轻震反馈
15984
+ state.joystickLastHoverKey = key;
15985
+ joystickHaptic(8);
15986
+ }
15987
+ if (hit.zone === "inner") {
15988
+ state.joystickHoverOuter = null;
15989
+ setJoystickOuterHighlight(null);
15990
+ startJoystickRepeat(hit.key);
15991
+ setJoystickInnerHighlight(hit.key);
15992
+ setJoystickCenterLabel(joystickLabelForKey(hit.key));
15993
+ } else if (hit.zone === "outer") {
15994
+ stopJoystickRepeat();
15995
+ setJoystickInnerHighlight(null);
15996
+ state.joystickHoverOuter = hit.key;
15997
+ setJoystickOuterHighlight(hit.key);
15998
+ setJoystickCenterLabel(joystickLabelForKey(hit.key));
15999
+ } else {
16000
+ stopJoystickRepeat();
16001
+ setJoystickInnerHighlight(null);
16002
+ state.joystickHoverOuter = null;
16003
+ setJoystickOuterHighlight(null);
16004
+ setJoystickCenterLabel("取消");
16005
+ }
16006
+ }
16007
+
16008
+ function setJoystickInnerHighlight(key) {
16009
+ if (!state.joystickRingEl) return;
16010
+ var btns = state.joystickRingEl.querySelectorAll(".wjr-inner");
16011
+ for (var i = 0; i < btns.length; i++) {
16012
+ btns[i].classList.toggle("is-repeating", btns[i].getAttribute("data-key") === key);
16013
+ }
16014
+ }
16015
+
16016
+ function setJoystickOuterHighlight(key) {
16017
+ if (!state.joystickRingEl) return;
16018
+ var btns = state.joystickRingEl.querySelectorAll(".wjr-outer");
16019
+ for (var i = 0; i < btns.length; i++) {
16020
+ btns[i].classList.toggle("is-hover", btns[i].getAttribute("data-key") === key);
16021
+ }
16022
+ }
16023
+
16024
+ // 把环圆心钳进视口,保证整圈(含标签)不被屏幕边裁掉。视口比环还小则回退到正中。
16025
+ function clampJoystickRingCenter(c) {
16026
+ var pad = JOYSTICK_RING_RADIUS + JOYSTICK_RING_VIEW_PAD;
16027
+ var vw = window.innerWidth, vh = window.innerHeight;
16028
+ return {
16029
+ x: vw < pad * 2 ? vw / 2 : Math.min(Math.max(pad, c.x), vw - pad),
16030
+ y: vh < pad * 2 ? vh / 2 : Math.min(Math.max(pad, c.y), vh - pad)
16031
+ };
16032
+ }
16033
+
16034
+ function openJoystickRing() {
16035
+ if (!state.joystickRingEl) return;
16036
+ // 圆心钳进视口后写回 state.joystickCenter:球球此刻已 is-ringing(opacity:0),
16037
+ // 圆心内移不露馅,且命中测试与可见环始终对齐。
16038
+ var c = clampJoystickRingCenter(state.joystickCenter || getJoystickCenter());
16039
+ state.joystickCenter = c;
16040
+ state.joystickRingEl.style.left = c.x + "px";
16041
+ state.joystickRingEl.style.top = c.y + "px";
16042
+ state.joystickRingEl.classList.add("active");
16043
+ state.joystickLastHoverKey = null;
16044
+ setJoystickCenterLabel("取消"); // 初始在死区,提示松手取消
16045
+ if (state.joystickBallEl) state.joystickBallEl.classList.add("is-ringing"); // 隐球球露中心
16046
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.add("active");
16047
+ joystickHaptic(10);
16048
+ }
16049
+
16050
+ function closeJoystickRing() {
16051
+ if (state.joystickRingEl) state.joystickRingEl.classList.remove("active");
16052
+ if (state.joystickBallEl) state.joystickBallEl.classList.remove("is-ringing");
16053
+ setJoystickInnerHighlight(null);
16054
+ setJoystickOuterHighlight(null);
16055
+ }
16056
+
16057
+ function startJoystickRepeat(key) {
16058
+ if (state.joystickRepeatKey === key) return; // 同方向不重启,保持节奏
16059
+ stopJoystickRepeat();
16060
+ state.joystickRepeatKey = key;
16061
+ sendJoystickKey(key); // 立即发一次
16062
+ state.joystickRepeatTimer = setInterval(function() {
16063
+ if (state.joystickRepeatKey) sendJoystickKey(state.joystickRepeatKey);
16064
+ }, JOYSTICK_REPEAT_MS);
16065
+ }
16066
+
16067
+ function stopJoystickRepeat() {
16068
+ if (state.joystickRepeatTimer) {
16069
+ clearInterval(state.joystickRepeatTimer);
16070
+ state.joystickRepeatTimer = null;
16071
+ }
16072
+ state.joystickRepeatKey = null;
16073
+ }
16074
+
16075
+ function sendJoystickKey(key) {
16076
+ if (key === "ctrl" || key === "alt" || key === "shift") {
16077
+ state.modifiers[key] = !state.modifiers[key];
16078
+ updateJoystickPanelUI();
16079
+ return;
16080
+ }
16081
+ var seq = buildPtySequence(key, {
16082
+ ctrl: state.modifiers.ctrl,
16083
+ alt: state.modifiers.alt,
16084
+ shift: state.modifiers.shift
16085
+ });
16086
+ if (seq) sendTerminalSequence(seq, key);
16087
+ clearModifiers(); // 发后自动清修饰键(应用到下一个发送的键)
16088
+ updateJoystickPanelUI();
16089
+ scheduleShortcutResync();
16090
+ }
16091
+
16092
+ function toggleJoystickPanel() {
16093
+ if (state.joystickPinnedOpen) closeJoystickPanel();
16094
+ else openJoystickPanel();
16095
+ }
16096
+
16097
+ function openJoystickPanel() {
16098
+ if (!state.joystickPanelEl || !state.joystickBallEl) return;
16099
+ state.joystickPinnedOpen = true;
16100
+ var r = state.joystickBallEl.getBoundingClientRect();
16101
+ // 面板锚定在球球上方(球球在右下→面板往左上展开),贴右/底边对齐
16102
+ state.joystickPanelEl.style.right = Math.max(JOYSTICK_EDGE_MARGIN, window.innerWidth - r.right) + "px";
16103
+ state.joystickPanelEl.style.bottom = Math.max(JOYSTICK_EDGE_MARGIN, window.innerHeight - r.top + 10) + "px";
16104
+ state.joystickPanelEl.classList.add("active");
16105
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.add("active");
16106
+ updateJoystickPanelUI();
16107
+ }
16108
+
16109
+ function closeJoystickPanel() {
16110
+ state.joystickPinnedOpen = false;
16111
+ if (state.joystickPanelEl) state.joystickPanelEl.classList.remove("active");
16112
+ if (state.joystickBackdropEl && state.joystickGesture == null) {
16113
+ state.joystickBackdropEl.classList.remove("active");
16114
+ }
16115
+ }
16116
+
16117
+ function updateJoystickPanelUI() {
16118
+ if (!state.joystickPanelEl) return;
16119
+ ["ctrl", "alt"].forEach(function(name) {
16120
+ var btn = state.joystickPanelEl.querySelector('.wjp-mod[data-key="' + name + '"]');
16121
+ if (btn) btn.classList.toggle("active", !!state.modifiers[name]);
16122
+ });
16123
+ }
16124
+
16125
+ function onJoystickPanelClick(e) {
16126
+ var btn = e.target && e.target.closest ? e.target.closest(".wjp-key") : null;
16127
+ if (!btn) return;
16128
+ e.preventDefault();
16129
+ e.stopPropagation();
16130
+ var key = btn.getAttribute("data-key");
16131
+ if (key) sendJoystickKey(key);
16132
+ }
16133
+
16134
+ function updateJoystickVisibility() {
16135
+ var root = state.joystickRootEl;
16136
+ if (!root) return;
16137
+ var available = isJoystickAvailable();
16138
+ root.classList.toggle("visible", available);
16139
+ if (!available) {
16140
+ // 不可用:强制收手势 + 收面板 + 停连发 + 清修饰键,杜绝残留
16141
+ if (state.joystickPointerId !== null || state.joystickGesture) endJoystickGesture();
16142
+ stopJoystickRepeat();
16143
+ if (state.joystickPinnedOpen) closeJoystickPanel();
16144
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.remove("active");
16145
+ }
16146
+ }
16147
+
16148
+ function teardownJoystick() {
16149
+ stopJoystickRepeat();
16150
+ if (state.joystickLongPressTimer) {
16151
+ clearTimeout(state.joystickLongPressTimer);
16152
+ state.joystickLongPressTimer = null;
16153
+ }
16154
+ if (state.joystickMoveHandler) {
16155
+ document.removeEventListener("pointermove", state.joystickMoveHandler);
16156
+ state.joystickMoveHandler = null;
16157
+ }
16158
+ if (state.joystickUpHandler) {
16159
+ document.removeEventListener("pointerup", state.joystickUpHandler);
16160
+ document.removeEventListener("pointercancel", state.joystickUpHandler);
16161
+ state.joystickUpHandler = null;
16162
+ }
16163
+ if (state.joystickResizeHandler) {
16164
+ window.removeEventListener("resize", state.joystickResizeHandler);
16165
+ window.removeEventListener("orientationchange", state.joystickResizeHandler);
16166
+ state.joystickResizeHandler = null;
16167
+ }
16168
+ if (state.joystickRootEl && state.joystickRootEl.parentNode) {
16169
+ state.joystickRootEl.parentNode.removeChild(state.joystickRootEl);
16170
+ }
16171
+ state.joystickRootEl = null;
16172
+ state.joystickRingEl = null;
16173
+ state.joystickPanelEl = null;
16174
+ state.joystickBackdropEl = null;
16175
+ state.joystickBallEl = null;
16176
+ state.joystickPointerId = null;
16177
+ state.joystickGesture = null;
16178
+ state.joystickPressStart = null;
16179
+ state.joystickHoverOuter = null;
16180
+ state.joystickCenter = null;
16181
+ state.joystickPinnedOpen = false;
16182
+ }
16183
+
15453
16184
  function observeTerminalResize() {
15454
16185
  var output = document.getElementById("output");
15455
16186
  if (!output) return;
@@ -15644,6 +16375,7 @@
15644
16375
  // 尺寸下创建的会话时,若新算出的 cols/rows 恰好等于上次值会被去重跳过,导致
15645
16376
  // 后端该会话列宽停在旧值、整段折行。teardown 重置后新会话首次 resize 必发出。
15646
16377
  state.lastResize = { cols: 0, rows: 0 };
16378
+ teardownJoystick();
15647
16379
  }
15648
16380
 
15649
16381
  function sendTerminalResize(cols, rows) {