@acertijosapps/electron-viewer 0.0.0-stage → 1.1.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.
- package/LICENSE +20 -0
- package/README.md +247 -2
- package/bin/electron-viewer.js +112 -0
- package/lib/cdp.js +112 -0
- package/lib/electron.js +920 -0
- package/mcp/server.js +418 -0
- package/media/viewer.jpg +0 -0
- package/package.json +59 -4
- package/public/index.html +63 -0
- package/public/style.css +246 -0
- package/public/viewer.js +507 -0
- package/server.js +428 -0
package/public/viewer.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
const $ = (s) => document.querySelector(s);
|
|
2
|
+
const canvas = $('#screen');
|
|
3
|
+
const keys = $('#keys');
|
|
4
|
+
const ctx = canvas.getContext('2d');
|
|
5
|
+
const windowSelect = $('#window');
|
|
6
|
+
const statusEl = $('#status');
|
|
7
|
+
const placeholder = $('#placeholder');
|
|
8
|
+
const placeholderText = $('#placeholder-text');
|
|
9
|
+
const launchButton = $('#launch');
|
|
10
|
+
const launchTop = $('#launch-top');
|
|
11
|
+
const attachForm = $('#attach');
|
|
12
|
+
const sizeSelect = $('#size');
|
|
13
|
+
const touchButton = $('#touch');
|
|
14
|
+
const controls = $('.controls');
|
|
15
|
+
|
|
16
|
+
const TARGETS_REFRESH_MS = 3000;
|
|
17
|
+
const DOUBLE_CLICK_MS = 500;
|
|
18
|
+
const DOUBLE_CLICK_SLOP_PX = 5;
|
|
19
|
+
const BUTTONS = ['left', 'middle', 'right'];
|
|
20
|
+
|
|
21
|
+
let ws = null;
|
|
22
|
+
let windowId = null;
|
|
23
|
+
let apps = [];
|
|
24
|
+
let project = null;
|
|
25
|
+
let meta = null;
|
|
26
|
+
let launching = false;
|
|
27
|
+
let framesThisSecond = 0;
|
|
28
|
+
let fps = 0;
|
|
29
|
+
|
|
30
|
+
function send(obj) {
|
|
31
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function api(method, route, body) {
|
|
35
|
+
const init = { method };
|
|
36
|
+
if (body) {
|
|
37
|
+
init.headers = { 'content-type': 'application/json' };
|
|
38
|
+
init.body = JSON.stringify(body);
|
|
39
|
+
}
|
|
40
|
+
const res = await fetch(`api/${route}`, init);
|
|
41
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
42
|
+
return res;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------- Video ----------
|
|
46
|
+
|
|
47
|
+
// Solo se decodifica el frame más reciente: si llegan más rápido de lo que se pintan, se saltan.
|
|
48
|
+
let pendingFrame = null;
|
|
49
|
+
let decoding = false;
|
|
50
|
+
|
|
51
|
+
async function drawLoop() {
|
|
52
|
+
decoding = true;
|
|
53
|
+
while (pendingFrame) {
|
|
54
|
+
const data = pendingFrame;
|
|
55
|
+
pendingFrame = null;
|
|
56
|
+
try {
|
|
57
|
+
const bitmap = await createImageBitmap(new Blob([data], { type: 'image/jpeg' }));
|
|
58
|
+
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
|
|
59
|
+
canvas.width = bitmap.width;
|
|
60
|
+
canvas.height = bitmap.height;
|
|
61
|
+
}
|
|
62
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
63
|
+
bitmap.close();
|
|
64
|
+
framesThisSecond++;
|
|
65
|
+
placeholder.hidden = true;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn('frame:', err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
decoding = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function onFrame(buf) {
|
|
74
|
+
pendingFrame = buf.subarray(1);
|
|
75
|
+
if (!decoding) drawLoop();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setInterval(() => {
|
|
79
|
+
fps = framesThisSecond;
|
|
80
|
+
framesThisSecond = 0;
|
|
81
|
+
renderStatus();
|
|
82
|
+
}, 1000);
|
|
83
|
+
|
|
84
|
+
// ---------- Estado ----------
|
|
85
|
+
|
|
86
|
+
// Un aviso (error, «guardando…») se queda unos segundos antes de volver a tamaño y fps.
|
|
87
|
+
const NOTE_MS = 6000;
|
|
88
|
+
let noteUntil = 0;
|
|
89
|
+
|
|
90
|
+
function renderStatus(text) {
|
|
91
|
+
if (typeof text === 'string') {
|
|
92
|
+
statusEl.textContent = text;
|
|
93
|
+
statusEl.title = text;
|
|
94
|
+
noteUntil = text ? performance.now() + NOTE_MS : 0;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!meta || performance.now() < noteUntil) return;
|
|
98
|
+
statusEl.title = '';
|
|
99
|
+
statusEl.textContent = `${meta.width}×${meta.height}${touchMode ? ' · touch' : ''} · ${fps} fps`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function onJson(msg) {
|
|
103
|
+
if (msg.type === 'meta') {
|
|
104
|
+
meta = msg;
|
|
105
|
+
sizeSelect.options[0].textContent = `Window size (${msg.width} × ${msg.height})`;
|
|
106
|
+
renderStatus();
|
|
107
|
+
} else if (msg.type === 'error') {
|
|
108
|
+
renderStatus(`Error: ${msg.message}`);
|
|
109
|
+
} else if (msg.type === 'closed') {
|
|
110
|
+
disconnect();
|
|
111
|
+
refreshTargets();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------- Ventanas ----------
|
|
116
|
+
|
|
117
|
+
const allWindows = () => apps.flatMap((app) => app.windows.map((w) => ({ ...w, app })));
|
|
118
|
+
const selected = () => allWindows().find((w) => w.id === windowId);
|
|
119
|
+
|
|
120
|
+
function showMessage(text, { launch = false } = {}) {
|
|
121
|
+
placeholder.hidden = false;
|
|
122
|
+
placeholderText.textContent = text;
|
|
123
|
+
launchButton.hidden = !launch;
|
|
124
|
+
launchButton.disabled = launching;
|
|
125
|
+
attachForm.hidden = !launch && !!apps.length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function setControlsEnabled(enabled) {
|
|
129
|
+
controls.querySelectorAll('button, select').forEach((b) => (b.disabled = !enabled));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function disconnect() {
|
|
133
|
+
const socket = ws;
|
|
134
|
+
ws = null;
|
|
135
|
+
socket?.close();
|
|
136
|
+
meta = null;
|
|
137
|
+
canvas.width = canvas.height = 0;
|
|
138
|
+
setControlsEnabled(false);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function connect(id) {
|
|
142
|
+
disconnect();
|
|
143
|
+
windowId = id;
|
|
144
|
+
showMessage('Connecting…');
|
|
145
|
+
setControlsEnabled(true);
|
|
146
|
+
const socket = new WebSocket(`ws://${location.host}/ws?window=${encodeURIComponent(id)}`);
|
|
147
|
+
socket.binaryType = 'arraybuffer';
|
|
148
|
+
socket.onmessage = (e) => (typeof e.data === 'string' ? onJson(JSON.parse(e.data)) : onFrame(new Uint8Array(e.data)));
|
|
149
|
+
socket.onclose = () => {
|
|
150
|
+
if (ws !== socket) return;
|
|
151
|
+
ws = null;
|
|
152
|
+
renderStatus('Disconnected; retrying…');
|
|
153
|
+
setTimeout(() => refreshTargets(true), 1500);
|
|
154
|
+
};
|
|
155
|
+
ws = socket;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderLaunch() {
|
|
159
|
+
const can = project?.launchable && !launching;
|
|
160
|
+
launchTop.hidden = !project?.launchable;
|
|
161
|
+
launchTop.disabled = !can;
|
|
162
|
+
launchTop.textContent = launching ? 'Launching…' : `Launch ${project?.name ?? 'app'}`;
|
|
163
|
+
launchTop.dataset.tip = project ? `Run ${project.command} in ${project.dir}` : '';
|
|
164
|
+
launchButton.textContent = launchTop.textContent;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function refreshTargets(reconnect = false) {
|
|
168
|
+
try {
|
|
169
|
+
apps = await (await api('GET', 'windows')).json();
|
|
170
|
+
} catch {
|
|
171
|
+
renderStatus('The viewer is not responding');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const windows = allWindows();
|
|
175
|
+
windowSelect.replaceChildren(
|
|
176
|
+
...apps.map((app) => {
|
|
177
|
+
const group = document.createElement('optgroup');
|
|
178
|
+
group.label = `${app.name} · :${app.cdpPort}${app.main ? '' : ' · no main'}`;
|
|
179
|
+
group.append(...app.windows.map((w) => new Option(w.title || w.url || w.id, w.id)));
|
|
180
|
+
return group;
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
windowSelect.hidden = !windows.length;
|
|
184
|
+
renderLaunch();
|
|
185
|
+
if (!windows.length) {
|
|
186
|
+
launchTop.hidden = true; // el placeholder ya tiene su botón
|
|
187
|
+
disconnect();
|
|
188
|
+
windowId = null;
|
|
189
|
+
renderStatus('');
|
|
190
|
+
if (launching) showMessage('Starting the app…');
|
|
191
|
+
else if (apps.length) showMessage(`${apps[0].name} has no windows open.`);
|
|
192
|
+
else showMessage('No Electron app found.', { launch: project?.launchable });
|
|
193
|
+
if (!project?.launchable && !apps.length) {
|
|
194
|
+
placeholderText.textContent = 'No Electron app found. Start your app with --remote-debugging-port=9222, or run the viewer from your project folder.';
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!selected()) {
|
|
199
|
+
const wanted = new URLSearchParams(location.search).get('window');
|
|
200
|
+
windowId = (windows.find((w) => w.id === wanted) || windows[0]).id;
|
|
201
|
+
}
|
|
202
|
+
windowSelect.value = windowId;
|
|
203
|
+
if (reconnect || !ws) connect(windowId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
windowSelect.onchange = () => {
|
|
207
|
+
history.replaceState(null, '', `?window=${encodeURIComponent(windowSelect.value)}`);
|
|
208
|
+
connect(windowSelect.value);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
async function launch() {
|
|
212
|
+
launching = true;
|
|
213
|
+
renderLaunch();
|
|
214
|
+
showMessage(`Starting ${project.name}…`);
|
|
215
|
+
try {
|
|
216
|
+
const app = await (await api('POST', 'launch', {})).json();
|
|
217
|
+
launching = false;
|
|
218
|
+
windowId = app?.windows?.[0]?.id ?? null;
|
|
219
|
+
if (windowId) history.replaceState(null, '', `?window=${encodeURIComponent(windowId)}`);
|
|
220
|
+
await refreshTargets(true);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
launching = false;
|
|
223
|
+
renderLaunch();
|
|
224
|
+
showMessage(`Could not start ${project.name}: ${err.message}`, { launch: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
launchButton.onclick = launch;
|
|
229
|
+
launchTop.onclick = launch;
|
|
230
|
+
|
|
231
|
+
attachForm.onsubmit = async (e) => {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
try {
|
|
234
|
+
await api('POST', 'attach', { port: Number($('#attach-port').value) });
|
|
235
|
+
await refreshTargets(true);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
placeholderText.textContent = err.message;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
async function loadProject() {
|
|
242
|
+
try {
|
|
243
|
+
project = await (await api('GET', 'project')).json();
|
|
244
|
+
} catch {
|
|
245
|
+
project = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
setInterval(() => refreshTargets(), TARGETS_REFRESH_MS);
|
|
250
|
+
|
|
251
|
+
// ---------- Controles ----------
|
|
252
|
+
|
|
253
|
+
async function control(route, body = {}) {
|
|
254
|
+
try {
|
|
255
|
+
return await (await api('POST', route, { window: windowId, ...body })).json();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
renderStatus(`${route} failed: ${err.message}`);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
$('#reload').onclick = () => control('reload');
|
|
263
|
+
|
|
264
|
+
$('#devtools').onclick = async () => {
|
|
265
|
+
const result = await control('devtools');
|
|
266
|
+
// Sin proceso main: se abre el frontend de DevTools que sirve el propio puerto CDP.
|
|
267
|
+
if (result?.url) window.open(result.url, '_blank');
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
sizeSelect.onchange = async () => {
|
|
271
|
+
const [width, height] = sizeSelect.value ? sizeSelect.value.split('x').map(Number) : [0, 0];
|
|
272
|
+
sizeSelect.value = '';
|
|
273
|
+
const result = await control('resize', { width, height });
|
|
274
|
+
if (result?.note) renderStatus(result.note);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
$('#screenshot').onclick = async () => {
|
|
278
|
+
if (!windowId) return;
|
|
279
|
+
renderStatus('Saving screenshot…');
|
|
280
|
+
try {
|
|
281
|
+
const res = await api('GET', `screenshot?window=${encodeURIComponent(windowId)}`);
|
|
282
|
+
const a = document.createElement('a');
|
|
283
|
+
a.href = URL.createObjectURL(await res.blob());
|
|
284
|
+
const name = (selected()?.app.name || 'electron').replace(/[^\w.-]+/g, '-');
|
|
285
|
+
a.download = `${name}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.png`;
|
|
286
|
+
a.click();
|
|
287
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
288
|
+
renderStatus('');
|
|
289
|
+
renderStatus();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
renderStatus(`Screenshot failed: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// ---------- Input ----------
|
|
296
|
+
|
|
297
|
+
// Posición normalizada (0–1) dentro del contenido de la ventana, descontando el letterbox de object-fit.
|
|
298
|
+
function toNorm(e) {
|
|
299
|
+
const r = canvas.getBoundingClientRect();
|
|
300
|
+
const ar = canvas.width / canvas.height;
|
|
301
|
+
let w = r.width;
|
|
302
|
+
let h = r.height;
|
|
303
|
+
let ox = 0;
|
|
304
|
+
let oy = 0;
|
|
305
|
+
if (r.width / r.height > ar) {
|
|
306
|
+
w = h * ar;
|
|
307
|
+
ox = (r.width - w) / 2;
|
|
308
|
+
} else {
|
|
309
|
+
h = w / ar;
|
|
310
|
+
oy = (r.height - h) / 2;
|
|
311
|
+
}
|
|
312
|
+
const x = (e.clientX - r.left - ox) / w;
|
|
313
|
+
const y = (e.clientY - r.top - oy) / h;
|
|
314
|
+
return { x, y };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const inside = (p) => p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1;
|
|
318
|
+
|
|
319
|
+
function modifiersOf(e) {
|
|
320
|
+
return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let lastDown = null;
|
|
324
|
+
let pressed = false;
|
|
325
|
+
|
|
326
|
+
// Modo touch: el puntero del visor se manda como un dedo (Input.dispatchTouchEvent). Se recuerda
|
|
327
|
+
// en este navegador.
|
|
328
|
+
let touchMode = false;
|
|
329
|
+
try {
|
|
330
|
+
touchMode = localStorage.getItem('electron-viewer:touch') === '1';
|
|
331
|
+
} catch {
|
|
332
|
+
// sin almacenamiento: empieza en mouse
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function renderTouch() {
|
|
336
|
+
touchButton.setAttribute('aria-pressed', String(touchMode));
|
|
337
|
+
touchButton.dataset.tip = touchMode ? 'Touch mode on: the pointer is a finger. Click to use the mouse' : 'Send the pointer as touch (tap, swipe, long press)';
|
|
338
|
+
canvas.style.cursor = touchMode ? 'pointer' : '';
|
|
339
|
+
renderStatus();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
touchButton.onclick = () => {
|
|
343
|
+
touchMode = !touchMode;
|
|
344
|
+
try {
|
|
345
|
+
localStorage.setItem('electron-viewer:touch', touchMode ? '1' : '0');
|
|
346
|
+
} catch {
|
|
347
|
+
// no se recuerda
|
|
348
|
+
}
|
|
349
|
+
renderTouch();
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
353
|
+
if (!meta || !canvas.width) return;
|
|
354
|
+
keys.focus({ preventScroll: true });
|
|
355
|
+
const p = toNorm(e);
|
|
356
|
+
if (!inside(p)) return;
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
canvas.setPointerCapture(e.pointerId);
|
|
359
|
+
pressed = true;
|
|
360
|
+
if (touchMode) {
|
|
361
|
+
if (e.button === 0) send({ type: 'touch', action: 'start', ...p });
|
|
362
|
+
else pressed = false;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const button = BUTTONS[e.button] || 'left';
|
|
366
|
+
const now = performance.now();
|
|
367
|
+
const near = lastDown && Math.hypot(e.clientX - lastDown.cx, e.clientY - lastDown.cy) < DOUBLE_CLICK_SLOP_PX;
|
|
368
|
+
const clickCount = near && lastDown.button === button && now - lastDown.t < DOUBLE_CLICK_MS ? Math.min(3, lastDown.count + 1) : 1;
|
|
369
|
+
lastDown = { t: now, cx: e.clientX, cy: e.clientY, button, count: clickCount };
|
|
370
|
+
send({ type: 'mouse', action: 'down', ...p, button, buttons: e.buttons, clickCount, modifiers: modifiersOf(e) });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
canvas.addEventListener('pointerup', (e) => {
|
|
374
|
+
if (!pressed) return;
|
|
375
|
+
pressed = false;
|
|
376
|
+
const p = toNorm(e);
|
|
377
|
+
if (touchMode) {
|
|
378
|
+
send({ type: 'touch', action: 'end', ...p });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
send({ type: 'mouse', action: 'up', x: p.x, y: p.y, button: BUTTONS[e.button] || 'left', buttons: e.buttons, clickCount: lastDown?.count || 1, modifiers: modifiersOf(e) });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Movimiento (hover y arrastre) a lo sumo una vez por frame de pantalla.
|
|
385
|
+
let moveEvent = null;
|
|
386
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
387
|
+
if (!meta || !canvas.width) return;
|
|
388
|
+
if (!moveEvent) requestAnimationFrame(flushMove);
|
|
389
|
+
moveEvent = e;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
function flushMove() {
|
|
393
|
+
const e = moveEvent;
|
|
394
|
+
moveEvent = null;
|
|
395
|
+
if (!e) return;
|
|
396
|
+
const p = toNorm(e);
|
|
397
|
+
if (touchMode) {
|
|
398
|
+
// Un dedo solo se mueve apoyado: sin hover.
|
|
399
|
+
if (pressed) send({ type: 'touch', action: 'move', ...p });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (!pressed && !inside(p)) return;
|
|
403
|
+
send({ type: 'mouse', action: 'move', ...p, buttons: e.buttons, modifiers: modifiersOf(e) });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
canvas.addEventListener('pointercancel', (e) => {
|
|
407
|
+
if (pressed && touchMode) send({ type: 'touch', action: 'cancel', ...toNorm(e) });
|
|
408
|
+
pressed = false;
|
|
409
|
+
});
|
|
410
|
+
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
411
|
+
|
|
412
|
+
let wheel = null;
|
|
413
|
+
canvas.addEventListener(
|
|
414
|
+
'wheel',
|
|
415
|
+
(e) => {
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
if (!meta) return;
|
|
418
|
+
const p = toNorm(e);
|
|
419
|
+
if (!inside(p)) return;
|
|
420
|
+
const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? canvas.clientHeight : 1;
|
|
421
|
+
if (!wheel) {
|
|
422
|
+
wheel = { ...p, dx: 0, dy: 0, modifiers: modifiersOf(e) };
|
|
423
|
+
requestAnimationFrame(() => {
|
|
424
|
+
send({ type: 'wheel', ...wheel });
|
|
425
|
+
wheel = null;
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
wheel.dx += e.deltaX * k;
|
|
429
|
+
wheel.dy += e.deltaY * k;
|
|
430
|
+
},
|
|
431
|
+
{ passive: false },
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// Teclado: un textarea invisible tiene el foco mientras se usa la app. Cada tecla va a la app
|
|
435
|
+
// (también ⌘C/⌘V, que en macOS se convierten en comandos de edición, y Tab). El texto que entra sin
|
|
436
|
+
// keydown (IME, dictado, herramientas que insertan texto) se manda entero.
|
|
437
|
+
function onKey(e, action) {
|
|
438
|
+
if (!meta || e.isComposing || e.keyCode === 229) return;
|
|
439
|
+
e.preventDefault();
|
|
440
|
+
const printable = [...e.key].length === 1 && !e.ctrlKey && !e.metaKey;
|
|
441
|
+
send({
|
|
442
|
+
type: 'key',
|
|
443
|
+
action,
|
|
444
|
+
key: e.key,
|
|
445
|
+
code: e.code,
|
|
446
|
+
keyCode: e.keyCode,
|
|
447
|
+
text: action === 'down' && printable ? e.key : undefined,
|
|
448
|
+
repeat: e.repeat,
|
|
449
|
+
modifiers: modifiersOf(e),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
keys.addEventListener('keydown', (e) => onKey(e, 'down'));
|
|
454
|
+
keys.addEventListener('keyup', (e) => onKey(e, 'up'));
|
|
455
|
+
keys.addEventListener('input', (e) => {
|
|
456
|
+
if (e.isComposing) return;
|
|
457
|
+
if (keys.value) send({ type: 'text', text: keys.value });
|
|
458
|
+
keys.value = '';
|
|
459
|
+
});
|
|
460
|
+
keys.addEventListener('compositionend', () => {
|
|
461
|
+
if (keys.value) send({ type: 'text', text: keys.value });
|
|
462
|
+
keys.value = '';
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ---------- Tooltips ----------
|
|
466
|
+
// Uno solo, colocado junto al control bajo el puntero o con el foco del teclado. Se usa
|
|
467
|
+
// pointerover en el documento porque los botones deshabilitados no reciben eventos propios.
|
|
468
|
+
|
|
469
|
+
const tip = $('#tip');
|
|
470
|
+
let tipFor = null;
|
|
471
|
+
|
|
472
|
+
function showTip(el) {
|
|
473
|
+
const text = el?.dataset.tip;
|
|
474
|
+
if (!text) return hideTip();
|
|
475
|
+
tipFor = el;
|
|
476
|
+
tip.textContent = text;
|
|
477
|
+
tip.hidden = false;
|
|
478
|
+
const r = el.getBoundingClientRect();
|
|
479
|
+
const t = tip.getBoundingClientRect();
|
|
480
|
+
const gap = 6;
|
|
481
|
+
// Debajo si cabe (barra de arriba), si no encima (controles de abajo).
|
|
482
|
+
const top = r.bottom + gap + t.height <= innerHeight ? r.bottom + gap : r.top - gap - t.height;
|
|
483
|
+
const left = Math.min(innerWidth - t.width - 8, Math.max(8, r.left + r.width / 2 - t.width / 2));
|
|
484
|
+
tip.style.top = `${Math.max(8, top)}px`;
|
|
485
|
+
tip.style.left = `${left}px`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function hideTip() {
|
|
489
|
+
tipFor = null;
|
|
490
|
+
tip.hidden = true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
document.addEventListener('pointerover', (e) => {
|
|
494
|
+
const el = e.target.closest?.('[data-tip]');
|
|
495
|
+
if (el !== tipFor) el ? showTip(el) : hideTip();
|
|
496
|
+
});
|
|
497
|
+
document.addEventListener('pointerdown', hideTip);
|
|
498
|
+
document.addEventListener('focusin', (e) => (e.target.matches?.(':focus-visible') ? showTip(e.target.closest('[data-tip]')) : hideTip()));
|
|
499
|
+
document.addEventListener('focusout', hideTip);
|
|
500
|
+
addEventListener('blur', hideTip);
|
|
501
|
+
// Con otro tamaño de página la posición ya no vale.
|
|
502
|
+
addEventListener('resize', hideTip);
|
|
503
|
+
|
|
504
|
+
renderTouch();
|
|
505
|
+
setControlsEnabled(false);
|
|
506
|
+
await loadProject();
|
|
507
|
+
refreshTargets();
|