agent-loft 0.2.34 → 0.2.36
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.
- package/README.md +10 -3
- package/package.json +7 -1
- package/src/channels/web/api.ts +27 -1
- package/src/channels/web/desktop.ts +183 -0
- package/src/channels/web/server.ts +64 -2
- package/src/channels/web/terminal.ts +154 -0
- package/src/core/config.ts +27 -0
- package/src/core/desktop.ts +281 -0
- package/src/core/terminals.ts +308 -0
- package/src/core/users.ts +16 -1
- package/web/css/remote.css +415 -0
- package/web/css/terminal.css +243 -0
- package/web/index.html +2 -0
- package/web/lib/dom.js +15 -0
- package/web/lib/ws.js +6 -1
- package/web/settings/general.js +96 -0
- package/web/settings/users.js +23 -1
- package/web/state.js +12 -1
- package/web/views/desktop-touch.js +344 -0
- package/web/views/desktop.js +539 -0
- package/web/views/layout.js +57 -1
- package/web/views/messages.js +3 -1
- package/web/views/remote.js +136 -0
- package/web/views/sidebar.js +6 -1
- package/web/views/tasks.js +2 -0
- package/web/views/terminal-touch.js +207 -0
- package/web/views/terminal.js +540 -0
- package/web/views/topbar.js +4 -3
package/web/settings/general.js
CHANGED
|
@@ -98,6 +98,102 @@ export function generalTab(cfg, panel) {
|
|
|
98
98
|
),
|
|
99
99
|
),
|
|
100
100
|
),
|
|
101
|
+
remoteSection(cfg),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 远程控制:终端、桌面各自的开关,没人连着多久结束,桌面从哪来。成员能不能用在「用户」里一个个开 */
|
|
106
|
+
function remoteSection(cfg) {
|
|
107
|
+
const r = cfg.remote ?? {};
|
|
108
|
+
const termOn = h("input", { type: "checkbox", checked: r.terminal !== false });
|
|
109
|
+
const deskOn = h("input", { type: "checkbox", checked: r.desktop !== false });
|
|
110
|
+
const keep = h("input", { type: "number", min: 1, max: 1440, value: r.keepAliveMin ?? 30 });
|
|
111
|
+
const source = h(
|
|
112
|
+
"select",
|
|
113
|
+
{},
|
|
114
|
+
h("option", { value: "auto" }, "共享这台机器当前的桌面(Linux,用 x11vnc)"),
|
|
115
|
+
h("option", { value: "vnc" }, "连已有的 VNC 服务(macOS 屏幕共享等)"),
|
|
116
|
+
);
|
|
117
|
+
source.value = r.desktopSource ?? "auto";
|
|
118
|
+
const display = h("input", { value: r.display ?? "", placeholder: "留空自动找,如 :0", autocapitalize: "off", spellcheck: "false" });
|
|
119
|
+
const host = h("input", { value: r.vncHost ?? "127.0.0.1", autocapitalize: "off", spellcheck: "false" });
|
|
120
|
+
const port = h("input", { type: "number", min: 1, max: 65535, value: r.vncPort ?? 5900 });
|
|
121
|
+
const password = h("input", { type: "password", value: r.vncPassword ?? "", autocomplete: "new-password", placeholder: "VNC 密码" });
|
|
122
|
+
const autoBox = h("div", { class: "field-group" }, field("X 显示", display, "一台机器上有好几个桌面(比如登录界面 :0、登录后的桌面 :1)时可以指定"));
|
|
123
|
+
const vncBox = h(
|
|
124
|
+
"div",
|
|
125
|
+
{ class: "field-group" },
|
|
126
|
+
field("地址", host),
|
|
127
|
+
field("端口", port),
|
|
128
|
+
field(
|
|
129
|
+
"密码",
|
|
130
|
+
password,
|
|
131
|
+
"macOS:屏幕共享 →「电脑设置…」里勾选「VNC 显示程序可以使用密码控制屏幕」并设这个密码。开通了远程控制的成员连桌面时,页面会拿到这个密码",
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
const sync = () => {
|
|
135
|
+
autoBox.hidden = source.value !== "auto";
|
|
136
|
+
vncBox.hidden = source.value !== "vnc";
|
|
137
|
+
};
|
|
138
|
+
source.addEventListener("change", sync);
|
|
139
|
+
sync();
|
|
140
|
+
// 终端、桌面用不了时的原因(没装 node-pty、没有图形桌面、没装 x11vnc…)
|
|
141
|
+
const status = h("div", { class: "error-text", hidden: true });
|
|
142
|
+
const loadStatus = () =>
|
|
143
|
+
api("/api/remote").then(
|
|
144
|
+
(st) => {
|
|
145
|
+
const lines = [
|
|
146
|
+
st.terminal.available ? null : `终端:${st.terminal.reason ?? "用不了"}`,
|
|
147
|
+
st.desktop.available ? null : `桌面:${st.desktop.reason ?? "这台机器没有桌面"}(远程控制里只有终端)`,
|
|
148
|
+
].filter(Boolean);
|
|
149
|
+
fill(status, lines.map((l, i) => [i ? h("br") : null, l]));
|
|
150
|
+
status.hidden = !lines.length;
|
|
151
|
+
},
|
|
152
|
+
() => {},
|
|
153
|
+
);
|
|
154
|
+
void loadStatus();
|
|
155
|
+
return h(
|
|
156
|
+
"div",
|
|
157
|
+
{ class: "section" },
|
|
158
|
+
h("h4", {}, "远程控制"),
|
|
159
|
+
h(
|
|
160
|
+
"div",
|
|
161
|
+
{ class: "muted" },
|
|
162
|
+
"侧栏「远程控制」里直接操作这台机器的桌面和终端,切回对话时连接留在后台。以运行 agent-loft 的系统用户执行,没有授权卡。管理员能用;成员要在「用户」里单独开通。",
|
|
163
|
+
),
|
|
164
|
+
status,
|
|
165
|
+
h("label", { class: "check" }, termOn, "网页终端"),
|
|
166
|
+
h("label", { class: "check" }, deskOn, "远程桌面"),
|
|
167
|
+
field("桌面来源", source),
|
|
168
|
+
autoBox,
|
|
169
|
+
vncBox,
|
|
170
|
+
field("没有页面连着多久后结束(分钟)", keep, "关掉页面、断线后 shell 继续运行,重新打开能接着用;共享桌面用的 x11vnc 也是这么久没人连就停掉"),
|
|
171
|
+
h(
|
|
172
|
+
"div",
|
|
173
|
+
{ class: "row" },
|
|
174
|
+
h(
|
|
175
|
+
"button",
|
|
176
|
+
{
|
|
177
|
+
class: "btn primary",
|
|
178
|
+
onclick: async () => {
|
|
179
|
+
const ok = await saveConfig(cfg, {
|
|
180
|
+
remote: {
|
|
181
|
+
terminal: termOn.checked,
|
|
182
|
+
desktop: deskOn.checked,
|
|
183
|
+
keepAliveMin: Number(keep.value),
|
|
184
|
+
desktopSource: source.value,
|
|
185
|
+
display: display.value.trim(),
|
|
186
|
+
vncHost: host.value.trim(),
|
|
187
|
+
vncPort: Number(port.value),
|
|
188
|
+
vncPassword: password.value,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
if (ok) void loadStatus();
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
"保存",
|
|
195
|
+
),
|
|
196
|
+
),
|
|
101
197
|
);
|
|
102
198
|
}
|
|
103
199
|
|
package/web/settings/users.js
CHANGED
|
@@ -66,7 +66,27 @@ export async function usersTab(panel) {
|
|
|
66
66
|
const roleBtn = h("button", { class: "btn small", disabled: self }, u.role === "admin" ? "设为成员" : "设为管理员");
|
|
67
67
|
roleBtn.onclick = act(roleBtn, () => patch(u, { role: u.role === "admin" ? "member" : "admin" }));
|
|
68
68
|
const disBtn = h("button", { class: "btn small", disabled: self }, u.disabled ? "启用" : "停用");
|
|
69
|
-
|
|
69
|
+
// 远程控制:管理员一直能用,成员要单独开
|
|
70
|
+
const remoteBtn =
|
|
71
|
+
u.role === "admin"
|
|
72
|
+
? null
|
|
73
|
+
: h("button", { class: "btn small", title: "远程控制:直接用服务器的终端和桌面,不经过授权" }, u.remote ? "关闭远程控制" : "开通远程控制");
|
|
74
|
+
if (remoteBtn) {
|
|
75
|
+
remoteBtn.onclick = act(remoteBtn, async () => {
|
|
76
|
+
if (!u.remote) {
|
|
77
|
+
const sure = await confirmDialog({
|
|
78
|
+
title: `给「${u.name}」开通远程控制?`,
|
|
79
|
+
message:
|
|
80
|
+
"他能在网页里直接用这台机器的终端和桌面,以运行 agent-loft 的系统用户(和登录桌面的人)做任何事,不经过授权卡:读写这台机器上的所有文件(包括别人的对话记录和配置里的密钥)、装软件、停掉服务。只开给完全信任的人。",
|
|
81
|
+
confirmText: "开通",
|
|
82
|
+
danger: true,
|
|
83
|
+
});
|
|
84
|
+
if (!sure) return false;
|
|
85
|
+
}
|
|
86
|
+
await patch(u, { remote: !u.remote });
|
|
87
|
+
toast(u.remote ? "已关闭,他开着的终端和桌面已经断开" : "已开通");
|
|
88
|
+
});
|
|
89
|
+
}
|
|
70
90
|
const memBtn = h("button", { class: "btn small", title: "删除他的「关于我」和所有记住的事。管理员看不到记忆的内容" }, "清空记忆");
|
|
71
91
|
memBtn.onclick = act(memBtn, async () => {
|
|
72
92
|
const sure = await confirmDialog({
|
|
@@ -112,6 +132,7 @@ export async function usersTab(panel) {
|
|
|
112
132
|
" ",
|
|
113
133
|
h("span", { class: "status-tag" }, u.role === "admin" ? "管理员" : "成员"),
|
|
114
134
|
u.disabled ? h("span", { class: "status-tag bad" }, "已停用") : null,
|
|
135
|
+
u.role !== "admin" && u.remote ? h("span", { class: "status-tag" }, "远程控制") : null,
|
|
115
136
|
),
|
|
116
137
|
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices} 台设备已登录${u.feishuLinks ? ` · 绑定了 ${u.feishuLinks} 个飞书账号` : ""}`),
|
|
117
138
|
extra,
|
|
@@ -119,6 +140,7 @@ export async function usersTab(panel) {
|
|
|
119
140
|
pwBtn,
|
|
120
141
|
roleBtn,
|
|
121
142
|
disBtn,
|
|
143
|
+
remoteBtn,
|
|
122
144
|
memBtn,
|
|
123
145
|
linkBtn,
|
|
124
146
|
rmBtn,
|
package/web/state.js
CHANGED
|
@@ -21,12 +21,23 @@ export const S = {
|
|
|
21
21
|
loadToken: 0,
|
|
22
22
|
/** 登录的用户 { id, name, role } */
|
|
23
23
|
user: null,
|
|
24
|
-
/**
|
|
24
|
+
/** 远程控制能用什么:{ allowed, terminal: {…}, desktop: {…} },见 /api/remote */
|
|
25
|
+
remote: null,
|
|
26
|
+
/** 远程控制在后台连着的:桌面连着没、开着几个终端(侧栏「远程控制」上的小绿点) */
|
|
27
|
+
remoteLive: { desktop: false, terminals: 0 },
|
|
28
|
+
/** 主区域在显示什么:null 是对话(或新对话),"tasks" 是定时任务页,"remote" 是远程控制 */
|
|
25
29
|
view: null,
|
|
26
30
|
};
|
|
27
31
|
|
|
28
32
|
export const isAdmin = () => S.user?.role === "admin";
|
|
29
33
|
|
|
34
|
+
/** 远程控制里能切到哪几页:桌面要这台机器有桌面才有;都没有就不显示入口 */
|
|
35
|
+
export function remoteTabs() {
|
|
36
|
+
const r = S.remote;
|
|
37
|
+
if (!r?.allowed) return [];
|
|
38
|
+
return [r.desktop?.allowed && r.desktop.available ? "desktop" : null, r.terminal?.allowed ? "terminal" : null].filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export const ui = {};
|
|
31
42
|
|
|
32
43
|
/**
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// ---------- 远程桌面:手机上的手势 ----------
|
|
2
|
+
// noVNC 自己的手势是「触摸屏」式的(手指点哪就点哪),而且没法在本地放大:手机上 1600 宽的桌面缩到 390 宽,
|
|
3
|
+
// 按钮小得点不准。这里在外层用捕获阶段把触摸事件全接过来自己处理:画面用 CSS 在本地放大、拖动,
|
|
4
|
+
// 鼠标按帧缓冲坐标直接发给 VNC(坐标按画布实际显示的位置换算,所以放大了也对得上)。
|
|
5
|
+
//
|
|
6
|
+
// 两种模式(像微软远程桌面):
|
|
7
|
+
// 触控板(默认):屏幕上有个指针,单指滑动移动它,轻点在指针处单击,双指轻点右键,长按后拖动是按住左键拖,
|
|
8
|
+
// 双指滑动滚动,双指捏合放大;放大后指针推到边上,画面跟着走。
|
|
9
|
+
// 触摸:点哪就点哪,长按右键,长按后拖动是按住左键拖;放大后单指拖动是挪画面,没放大时单指拖动是按住左键拖。
|
|
10
|
+
|
|
11
|
+
const MOVE_SLOP = 8;
|
|
12
|
+
const LONG_PRESS_MS = 420;
|
|
13
|
+
const MAX_ZOOM = 5;
|
|
14
|
+
/** 双指滑动多少像素算滚一格 */
|
|
15
|
+
const WHEEL_STEP = 22;
|
|
16
|
+
|
|
17
|
+
const BTN = { left: 1, middle: 2, right: 4, up: 8, down: 16, wleft: 32, wright: 64 };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* viewport:裁剪用的外框;screen:交给 noVNC 的那块(在它上面做 CSS 放大)。
|
|
21
|
+
* send(x, y, mask):按帧缓冲坐标发鼠标;mods:点击前后按下 / 松开粘住的修饰键(Ctrl、Alt…)
|
|
22
|
+
*/
|
|
23
|
+
export function createTouch({ viewport, screen, send, active, mods, onZoom }) {
|
|
24
|
+
const Z = { s: 1, tx: 0, ty: 0 };
|
|
25
|
+
/** 指针在帧缓冲里的位置(触控板模式下画出来) */
|
|
26
|
+
const P = { x: 0, y: 0 };
|
|
27
|
+
let mode = localStorage.getItem("aim.deskTouch") === "touch" ? "touch" : "trackpad";
|
|
28
|
+
let buttons = 0;
|
|
29
|
+
let g = null;
|
|
30
|
+
let movePending = false;
|
|
31
|
+
|
|
32
|
+
const cursor = document.createElement("div");
|
|
33
|
+
cursor.className = "desk-cursor";
|
|
34
|
+
cursor.innerHTML =
|
|
35
|
+
'<svg viewBox="0 0 20 24" aria-hidden="true"><path d="M2 1.5v18l4.6-4.4 3 7 3.2-1.4-3-6.8H16Z" fill="#fff" stroke="#111" stroke-width="1.4" stroke-linejoin="round"/></svg>';
|
|
36
|
+
viewport.append(cursor);
|
|
37
|
+
|
|
38
|
+
const canvas = () => screen.querySelector("canvas");
|
|
39
|
+
const fb = () => {
|
|
40
|
+
const c = canvas();
|
|
41
|
+
return c && c.width > 0 ? { w: c.width, h: c.height } : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ---------- 本地放大 / 拖动 ----------
|
|
45
|
+
|
|
46
|
+
/** 放大后画面不能拖出边界;比视口小的那个方向居中 */
|
|
47
|
+
function clamp() {
|
|
48
|
+
const c = canvas();
|
|
49
|
+
if (!c) return;
|
|
50
|
+
const vw = viewport.clientWidth;
|
|
51
|
+
const vh = viewport.clientHeight;
|
|
52
|
+
// offsetLeft / offsetWidth 是没算 transform 的布局值(.desk-screen 是 offsetParent)
|
|
53
|
+
const axis = (t, off, size, view) => {
|
|
54
|
+
const scaled = size * Z.s;
|
|
55
|
+
if (scaled <= view) return (view - scaled) / 2 - off * Z.s;
|
|
56
|
+
return Math.min(-off * Z.s, Math.max(view - scaled - off * Z.s, t));
|
|
57
|
+
};
|
|
58
|
+
Z.tx = axis(Z.tx, c.offsetLeft, c.offsetWidth, vw);
|
|
59
|
+
Z.ty = axis(Z.ty, c.offsetTop, c.offsetHeight, vh);
|
|
60
|
+
if (Z.s === 1) Z.tx = Z.ty = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function apply() {
|
|
64
|
+
// 藏着(切到终端、回了对话)时量出来是 0:别拿它重算,等露出来再说
|
|
65
|
+
if (!viewport.clientWidth || !viewport.clientHeight) return;
|
|
66
|
+
clamp();
|
|
67
|
+
screen.style.transform = Z.s === 1 ? "" : `translate(${Z.tx}px, ${Z.ty}px) scale(${Z.s})`;
|
|
68
|
+
placeCursor();
|
|
69
|
+
onZoom(Z.s);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 以 (cx, cy)(页面坐标)为中心缩放到 s */
|
|
73
|
+
function zoomAt(s, cx, cy) {
|
|
74
|
+
const r = viewport.getBoundingClientRect();
|
|
75
|
+
const lx = (cx - r.left - Z.tx) / Z.s;
|
|
76
|
+
const ly = (cy - r.top - Z.ty) / Z.s;
|
|
77
|
+
Z.s = Math.max(1, Math.min(MAX_ZOOM, s));
|
|
78
|
+
Z.tx = cx - r.left - lx * Z.s;
|
|
79
|
+
Z.ty = cy - r.top - ly * Z.s;
|
|
80
|
+
apply();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pan(dx, dy) {
|
|
84
|
+
if (Z.s === 1) return;
|
|
85
|
+
Z.tx += dx;
|
|
86
|
+
Z.ty += dy;
|
|
87
|
+
apply();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------- 坐标 ----------
|
|
91
|
+
|
|
92
|
+
function toFb(clientX, clientY) {
|
|
93
|
+
const c = canvas();
|
|
94
|
+
const size = fb();
|
|
95
|
+
if (!c || !size) return null;
|
|
96
|
+
const r = c.getBoundingClientRect();
|
|
97
|
+
return {
|
|
98
|
+
x: Math.round(Math.max(0, Math.min(size.w - 1, ((clientX - r.left) / r.width) * size.w))),
|
|
99
|
+
y: Math.round(Math.max(0, Math.min(size.h - 1, ((clientY - r.top) / r.height) * size.h))),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 指针在视口里的位置(像素) */
|
|
104
|
+
function cursorInView() {
|
|
105
|
+
const c = canvas();
|
|
106
|
+
const size = fb();
|
|
107
|
+
if (!c || !size) return null;
|
|
108
|
+
const r = c.getBoundingClientRect();
|
|
109
|
+
const v = viewport.getBoundingClientRect();
|
|
110
|
+
return { x: r.left - v.left + (P.x / size.w) * r.width, y: r.top - v.top + (P.y / size.h) * r.height };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function placeCursor() {
|
|
114
|
+
const at = mode === "trackpad" && active() ? cursorInView() : null;
|
|
115
|
+
cursor.hidden = !at;
|
|
116
|
+
if (at) cursor.style.transform = `translate(${at.x}px, ${at.y}px)`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 放大时指针推到边上:画面跟着走 */
|
|
120
|
+
function followCursor() {
|
|
121
|
+
const at = cursorInView();
|
|
122
|
+
if (!at || Z.s === 1) return;
|
|
123
|
+
const m = 36;
|
|
124
|
+
const dx = at.x < m ? m - at.x : at.x > viewport.clientWidth - m ? viewport.clientWidth - m - at.x : 0;
|
|
125
|
+
const dy = at.y < m ? m - at.y : at.y > viewport.clientHeight - m ? viewport.clientHeight - m - at.y : 0;
|
|
126
|
+
if (dx || dy) pan(dx, dy);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------- 发鼠标 ----------
|
|
130
|
+
|
|
131
|
+
function moveTo(x, y) {
|
|
132
|
+
const size = fb();
|
|
133
|
+
if (!size) return;
|
|
134
|
+
P.x = Math.max(0, Math.min(size.w - 1, Math.round(x)));
|
|
135
|
+
P.y = Math.max(0, Math.min(size.h - 1, Math.round(y)));
|
|
136
|
+
placeCursor();
|
|
137
|
+
// 一帧最多发一次
|
|
138
|
+
if (movePending) return;
|
|
139
|
+
movePending = true;
|
|
140
|
+
requestAnimationFrame(() => {
|
|
141
|
+
movePending = false;
|
|
142
|
+
send(P.x, P.y, buttons);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function press(mask) {
|
|
147
|
+
if (!buttons) mods.down();
|
|
148
|
+
buttons |= mask;
|
|
149
|
+
send(P.x, P.y, buttons);
|
|
150
|
+
cursor.classList.add("pressed");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function release(mask) {
|
|
154
|
+
buttons &= ~mask;
|
|
155
|
+
send(P.x, P.y, buttons);
|
|
156
|
+
if (!buttons) {
|
|
157
|
+
mods.up();
|
|
158
|
+
cursor.classList.remove("pressed");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function click(mask) {
|
|
163
|
+
press(mask);
|
|
164
|
+
release(mask);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function wheel(mask, times) {
|
|
168
|
+
for (let i = 0; i < times; i++) {
|
|
169
|
+
send(P.x, P.y, buttons | mask);
|
|
170
|
+
send(P.x, P.y, buttons);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------- 手势 ----------
|
|
175
|
+
|
|
176
|
+
const point = (t) => ({ x: t.clientX, y: t.clientY });
|
|
177
|
+
const mid = (ts) => ({ x: (ts[0].clientX + ts[1].clientX) / 2, y: (ts[0].clientY + ts[1].clientY) / 2 });
|
|
178
|
+
const dist = (ts) => Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY);
|
|
179
|
+
const vibrate = () => navigator.vibrate?.(12);
|
|
180
|
+
|
|
181
|
+
function onLong() {
|
|
182
|
+
if (!g || g.moved || g.fingers !== 1) return;
|
|
183
|
+
g.hold = true;
|
|
184
|
+
vibrate();
|
|
185
|
+
cursor.classList.add("armed");
|
|
186
|
+
// 触摸模式:长按的地方就是要点的地方
|
|
187
|
+
if (mode === "touch") {
|
|
188
|
+
const p = toFb(g.x0, g.y0);
|
|
189
|
+
if (p) moveTo(p.x, p.y);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function start(e) {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
e.stopPropagation();
|
|
196
|
+
const ts = e.touches;
|
|
197
|
+
if (!g) {
|
|
198
|
+
const p = point(ts[0]);
|
|
199
|
+
g = { x0: p.x, y0: p.y, last: p, t: performance.now(), v: 0, fingers: 1, max: 1, moved: false, hold: false, dragging: false };
|
|
200
|
+
g.timer = setTimeout(onLong, LONG_PRESS_MS);
|
|
201
|
+
}
|
|
202
|
+
g.fingers = ts.length;
|
|
203
|
+
g.max = Math.max(g.max, ts.length);
|
|
204
|
+
if (ts.length >= 2) {
|
|
205
|
+
clearTimeout(g.timer);
|
|
206
|
+
g.two = { d0: dist(ts), s0: Z.s, c: mid(ts), kind: null, acc: { x: 0, y: 0 } };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function move(e) {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
e.stopPropagation();
|
|
213
|
+
if (!g) return;
|
|
214
|
+
const ts = e.touches;
|
|
215
|
+
// 双指:捏合放大,或者滚动
|
|
216
|
+
if (ts.length >= 2 && g.two) {
|
|
217
|
+
const c = mid(ts);
|
|
218
|
+
const d = dist(ts);
|
|
219
|
+
const two = g.two;
|
|
220
|
+
if (!two.kind) {
|
|
221
|
+
if (Math.abs(d - two.d0) > 24) two.kind = "pinch";
|
|
222
|
+
else if (Math.hypot(c.x - two.c.x, c.y - two.c.y) > 10) two.kind = "scroll";
|
|
223
|
+
else return;
|
|
224
|
+
g.moved = true;
|
|
225
|
+
}
|
|
226
|
+
if (two.kind === "pinch") {
|
|
227
|
+
zoomAt(two.s0 * (d / two.d0), c.x, c.y);
|
|
228
|
+
pan(c.x - two.c.x, c.y - two.c.y);
|
|
229
|
+
} else {
|
|
230
|
+
// 跟手机上一样的「自然滚动」:手指往上推,内容往下走
|
|
231
|
+
two.acc.x += c.x - two.c.x;
|
|
232
|
+
two.acc.y += c.y - two.c.y;
|
|
233
|
+
const ny = Math.trunc(two.acc.y / WHEEL_STEP);
|
|
234
|
+
const nx = Math.trunc(two.acc.x / WHEEL_STEP);
|
|
235
|
+
if (ny) wheel(ny < 0 ? BTN.down : BTN.up, Math.abs(ny));
|
|
236
|
+
if (nx) wheel(nx < 0 ? BTN.wright : BTN.wleft, Math.abs(nx));
|
|
237
|
+
two.acc.y -= ny * WHEEL_STEP;
|
|
238
|
+
two.acc.x -= nx * WHEEL_STEP;
|
|
239
|
+
}
|
|
240
|
+
two.c = c;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// 双指松开了一个:剩下那个不算新手势,免得指针跳一下
|
|
244
|
+
if (g.max >= 2) return;
|
|
245
|
+
const p = point(ts[0]);
|
|
246
|
+
if (!g.moved && Math.hypot(p.x - g.x0, p.y - g.y0) < MOVE_SLOP) return;
|
|
247
|
+
g.moved = true;
|
|
248
|
+
clearTimeout(g.timer);
|
|
249
|
+
const now = performance.now();
|
|
250
|
+
const dx = p.x - g.last.x;
|
|
251
|
+
const dy = p.y - g.last.y;
|
|
252
|
+
const dt = Math.max(1, now - g.t);
|
|
253
|
+
g.last = p;
|
|
254
|
+
g.t = now;
|
|
255
|
+
if (mode === "trackpad") {
|
|
256
|
+
if (g.hold && !g.dragging) {
|
|
257
|
+
g.dragging = true;
|
|
258
|
+
press(BTN.left);
|
|
259
|
+
}
|
|
260
|
+
// 屏幕上挪多少,指针在画面上就挪多少;划得快再加速一点
|
|
261
|
+
const c = canvas();
|
|
262
|
+
const size = fb();
|
|
263
|
+
if (!c || !size) return;
|
|
264
|
+
const perPx = size.w / c.getBoundingClientRect().width;
|
|
265
|
+
const speed = Math.hypot(dx, dy) / dt;
|
|
266
|
+
const accel = 1 + Math.min(2, Math.max(0, (speed - 0.25) * 1.6));
|
|
267
|
+
moveTo(P.x + dx * perPx * accel, P.y + dy * perPx * accel);
|
|
268
|
+
followCursor();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// 触摸模式
|
|
272
|
+
if (g.hold || Z.s === 1) {
|
|
273
|
+
if (!g.dragging) {
|
|
274
|
+
const from = toFb(g.x0, g.y0);
|
|
275
|
+
if (from) moveTo(from.x, from.y);
|
|
276
|
+
g.dragging = true;
|
|
277
|
+
press(BTN.left);
|
|
278
|
+
}
|
|
279
|
+
const to = toFb(p.x, p.y);
|
|
280
|
+
if (to) moveTo(to.x, to.y);
|
|
281
|
+
} else {
|
|
282
|
+
pan(dx, dy);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function end(e) {
|
|
287
|
+
// touchcancel 不能取消(cancelable 为 false),硬取消浏览器会报错
|
|
288
|
+
if (e.cancelable) e.preventDefault();
|
|
289
|
+
e.stopPropagation();
|
|
290
|
+
if (!g || e.touches.length > 0) return;
|
|
291
|
+
clearTimeout(g.timer);
|
|
292
|
+
cursor.classList.remove("armed");
|
|
293
|
+
const gest = g;
|
|
294
|
+
g = null;
|
|
295
|
+
if (gest.dragging) return release(BTN.left);
|
|
296
|
+
// 被系统打断了(来电、下拉通知栏):不是松手,别点下去
|
|
297
|
+
if (gest.moved || e.type === "touchcancel") return;
|
|
298
|
+
if (mode === "touch") {
|
|
299
|
+
const p = toFb(gest.x0, gest.y0);
|
|
300
|
+
if (p) moveTo(p.x, p.y);
|
|
301
|
+
}
|
|
302
|
+
// 双指轻点、长按不动:右键;轻点:左键
|
|
303
|
+
click(gest.max >= 2 || gest.hold ? BTN.right : BTN.left);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 画面上浮着的按钮(右下角「还原」)照常交给浏览器去点,别当成在桌面上点
|
|
307
|
+
const mine = (e) => active() && !e.target.closest?.("button");
|
|
308
|
+
const opts = { capture: true, passive: false };
|
|
309
|
+
viewport.addEventListener("touchstart", (e) => mine(e) && start(e), opts);
|
|
310
|
+
viewport.addEventListener("touchmove", (e) => g && move(e), opts);
|
|
311
|
+
viewport.addEventListener("touchend", (e) => g && end(e), opts);
|
|
312
|
+
viewport.addEventListener("touchcancel", (e) => g && end(e), opts);
|
|
313
|
+
// 转屏、窗口变了:画面重新摆好
|
|
314
|
+
new ResizeObserver(() => apply()).observe(viewport);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
get mode() {
|
|
318
|
+
return mode;
|
|
319
|
+
},
|
|
320
|
+
setMode(next) {
|
|
321
|
+
mode = next;
|
|
322
|
+
localStorage.setItem("aim.deskTouch", mode);
|
|
323
|
+
placeCursor();
|
|
324
|
+
},
|
|
325
|
+
get zoom() {
|
|
326
|
+
return Z.s;
|
|
327
|
+
},
|
|
328
|
+
resetZoom() {
|
|
329
|
+
Z.s = 1;
|
|
330
|
+
apply();
|
|
331
|
+
},
|
|
332
|
+
/** 连上了:指针放到中间,画面还原 */
|
|
333
|
+
connected() {
|
|
334
|
+
const size = fb();
|
|
335
|
+
if (size) {
|
|
336
|
+
P.x = Math.round(size.w / 2);
|
|
337
|
+
P.y = Math.round(size.h / 2);
|
|
338
|
+
}
|
|
339
|
+
Z.s = 1;
|
|
340
|
+
apply();
|
|
341
|
+
},
|
|
342
|
+
refresh: placeCursor,
|
|
343
|
+
};
|
|
344
|
+
}
|