fix(picow): make the device panel a draggable, non-modal floating window

The first iframe panel was a full-screen modal with a backdrop: it
covered the canvas and blocked the editor, so you couldn't watch the
board react or keep clicking buttons/wiring while it was open, and it
couldn't be moved.

Now it's a small floating panel docked top-right, draggable by its title
bar, resizable, with NO backdrop — the canvas and editor stay fully
interactive underneath.

Also: the relay and async-led device pages now show a big colored ON/OFF
indicator (these are web-server demos; the GP2/onboard LED isn't drawn on
the canvas, so the panel is where you see the state flip).
This commit is contained in:
David Montero 2026-06-14 06:28:29 +02:00
parent 80d2086907
commit 1478f7f543
2 changed files with 88 additions and 50 deletions

View File

@ -73,13 +73,21 @@ async def wifi_connect():
led_on = False
def page():
# The board's onboard LED isn't drawn on the canvas, so show the state
# here; the buttons reload the page so you can see it flip.
state = "ON" if led_on else "OFF"
return ("<!DOCTYPE html><html><body><h2>Pico W LED: %s</h2>"
"<button onclick=\\"fetch('on').then(()=>location.reload())\\">ON</button> "
"<button onclick=\\"fetch('off').then(()=>location.reload())\\">OFF</button>"
"</body></html>") % state
# The board's onboard LED isn't drawn on the canvas, so show a big
# state indicator here; the buttons reload so you can see it flip.
color = "#22c55e" if led_on else "#666"
label = "LED ON" if led_on else "LED OFF"
return ("<!DOCTYPE html><html><head>"
"<meta name=viewport content='width=device-width,initial-scale=1'></head>"
"<body style='font-family:sans-serif;text-align:center;padding:24px;background:#fafafa'>"
"<h2>Pico W Onboard LED</h2>"
"<div style='margin:18px auto;width:170px;padding:26px 0;border-radius:14px;"
"background:%s;color:#fff;font-size:24px;font-weight:bold'>%s</div>"
"<button style='font-size:18px;padding:12px 28px;margin:6px' "
"onclick=\\"fetch('on').then(()=>location.reload())\\">ON</button>"
"<button style='font-size:18px;padding:12px 28px;margin:6px' "
"onclick=\\"fetch('off').then(()=>location.reload())\\">OFF</button>"
"</body></html>") % (color, label)
async def handle_client(reader, writer):
global led_on
@ -143,11 +151,19 @@ s.listen(1)
print("Open browser: http://%s/" % ip)
def page():
state = "ON" if relay_state == 0 else "OFF"
return ("<html><body><h2>Pico W Relay: %s</h2>"
"<button onclick=\\"fetch('on').then(()=>location.reload())\\">ON</button> "
"<button onclick=\\"fetch('off').then(()=>location.reload())\\">OFF</button>"
"</body></html>") % state
on = (relay_state == 0)
color = "#22c55e" if on else "#666"
label = "RELAY ON" if on else "RELAY OFF"
return ("<html><head><meta name=viewport content='width=device-width,initial-scale=1'></head>"
"<body style='font-family:sans-serif;text-align:center;padding:24px;background:#fafafa'>"
"<h2>Pico W Relay (GP2)</h2>"
"<div style='margin:18px auto;width:170px;padding:26px 0;border-radius:14px;"
"background:%s;color:#fff;font-size:24px;font-weight:bold'>%s</div>"
"<button style='font-size:18px;padding:12px 28px;margin:6px' "
"onclick=\\"fetch('on').then(()=>location.reload())\\">ON</button>"
"<button style='font-size:18px;padding:12px 28px;margin:6px' "
"onclick=\\"fetch('off').then(()=>location.reload())\\">OFF</button>"
"</body></html>") % (color, label)
# Wrap each client in try/except: a browser that drops the connection
# mid-response would otherwise raise ECONNRESET and kill the server loop.

View File

@ -1,83 +1,105 @@
/**
* openDeviceGateway
*
* Opens an emulated board's IoT-gateway page inside an in-app iframe panel
* (same tab) instead of a new browser tab.
* Opens an emulated board's IoT-gateway page in a small, FLOATING, draggable
* panel docked to the top-right NOT a modal overlay and NOT a new tab.
*
* Why: the Raspberry Pi Pico W emulation runs in THIS browser tab, driven by
* requestAnimationFrame. Opening the gateway in a new tab backgrounds the
* emulation tab, the browser pauses its rAF, the simulated chip freezes, and
* the gateway can no longer reach the server running on it (the request times
* out / returns 502). Rendering the served page in an iframe keeps the
* emulation tab in the foreground, so the chip keeps running and answers.
* Why not a new tab: the Raspberry Pi Pico W emulation runs in THIS browser
* tab, driven by requestAnimationFrame. A new tab backgrounds the emulation
* tab, the browser pauses its rAF, the simulated chip freezes, and the gateway
* can no longer reach the server on it (request times out / 502). An in-tab
* iframe keeps the emulation in the foreground so the chip keeps answering.
*
* (The ESP32 doesn't need this its server runs in QEMU on the backend, which
* is unaffected by browser tab visibility.)
* Why not a modal: the panel must NOT block the canvas or the editor the
* user needs to watch the board react (LED/relay) and keep pressing buttons,
* wiring components, editing code while the device page is open. So: no
* backdrop, draggable by its title bar, resizable, parked out of the way.
*
* Plain DOM, no React state, so it can be invoked from anywhere (serial-monitor
* link, canvas WiFi badge) without threading props.
* (The ESP32 doesn't need any of this its server runs in QEMU on the
* backend, immune to tab visibility so it still opens in a new tab.)
*/
const OVERLAY_ID = 'velxio-device-gateway-overlay';
const PANEL_ID = 'velxio-device-gateway-panel';
export function openDeviceGateway(url: string): void {
if (typeof document === 'undefined') return;
document.getElementById(OVERLAY_ID)?.remove();
const overlay = document.createElement('div');
overlay.id = OVERLAY_ID;
overlay.style.cssText =
'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,0.55);' +
'display:flex;align-items:center;justify-content:center;';
document.getElementById(PANEL_ID)?.remove();
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.style.cssText =
'background:#1e1e1e;border:1px solid #3a3a3a;border-radius:10px;' +
'width:min(440px,94vw);height:min(640px,90vh);display:flex;' +
'flex-direction:column;overflow:hidden;box-shadow:0 12px 48px rgba(0,0,0,0.5);';
'position:fixed;top:64px;right:20px;z-index:100000;' +
'width:340px;height:500px;display:flex;flex-direction:column;' +
'background:#1e1e1e;border:1px solid #444;border-radius:10px;overflow:hidden;' +
'box-shadow:0 12px 48px rgba(0,0,0,0.55);resize:both;min-width:240px;min-height:220px;';
const bar = document.createElement('div');
bar.style.cssText =
'display:flex;align-items:center;justify-content:space-between;gap:12px;' +
'padding:8px 12px;background:#252526;color:#ddd;' +
'font:13px -apple-system,BlinkMacSystemFont,sans-serif;border-bottom:1px solid #3a3a3a;';
'flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:10px;' +
'padding:7px 10px;background:#2d2d2e;color:#ddd;cursor:move;user-select:none;' +
'font:12px -apple-system,BlinkMacSystemFont,sans-serif;border-bottom:1px solid #444;';
const title = document.createElement('span');
title.textContent = 'Device web page (IoT Gateway)';
title.style.cssText = 'font-weight:600;';
title.textContent = 'Device web page';
title.style.cssText = 'font-weight:600;white-space:nowrap;';
const right = document.createElement('div');
right.style.cssText = 'display:flex;align-items:center;gap:14px;';
right.style.cssText = 'display:flex;align-items:center;gap:12px;';
const reload = document.createElement('button');
reload.type = 'button';
reload.textContent = 'Reload';
reload.style.cssText = 'background:none;border:none;color:#4fc3f7;cursor:pointer;font-size:13px;padding:0;';
reload.style.cssText = 'background:none;border:none;color:#4fc3f7;cursor:pointer;font-size:12px;padding:0;';
const openTab = document.createElement('a');
openTab.textContent = 'Open in tab';
openTab.href = url;
openTab.target = '_blank';
openTab.rel = 'noreferrer';
openTab.style.cssText = 'color:#4fc3f7;text-decoration:none;font-size:13px;';
openTab.style.cssText = 'color:#4fc3f7;text-decoration:none;font-size:12px;';
const close = document.createElement('button');
close.type = 'button';
close.textContent = 'Close';
close.style.cssText = 'background:#3a3a3a;border:none;color:#fff;cursor:pointer;font-size:13px;border-radius:5px;padding:4px 10px;';
close.style.cssText = 'background:#444;border:none;color:#fff;cursor:pointer;font-size:12px;border-radius:4px;padding:3px 9px;';
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.style.cssText = 'flex:1;border:none;width:100%;background:#fff;';
iframe.style.cssText = 'flex:1 1 auto;border:none;width:100%;background:#fff;';
reload.onclick = () => { iframe.src = url; };
const dismiss = () => overlay.remove();
close.onclick = dismiss;
overlay.onclick = (e) => { if (e.target === overlay) dismiss(); };
close.onclick = () => panel.remove();
// Drag by the title bar. Switch from right-anchored to left/top on grab so
// the panel follows the cursor cleanly.
bar.addEventListener('mousedown', (e) => {
if (e.target !== bar && e.target !== title) return; // not on a button
e.preventDefault();
const rect = panel.getBoundingClientRect();
const dx = e.clientX - rect.left;
const dy = e.clientY - rect.top;
panel.style.right = 'auto';
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
// While dragging, don't let the iframe swallow mouse events.
iframe.style.pointerEvents = 'none';
const onMove = (m: MouseEvent) => {
const maxX = window.innerWidth - 60;
const maxY = window.innerHeight - 30;
panel.style.left = Math.max(0, Math.min(maxX, m.clientX - dx)) + 'px';
panel.style.top = Math.max(0, Math.min(maxY, m.clientY - dy)) + 'px';
};
const onUp = () => {
iframe.style.pointerEvents = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
right.append(reload, openTab, close);
bar.append(title, right);
panel.append(bar, iframe);
overlay.append(panel);
document.body.append(overlay);
document.body.append(panel);
}