diff --git a/frontend/src/data/examples-picow-wifi.ts b/frontend/src/data/examples-picow-wifi.ts index 31d69a7d..1ddf0352 100644 --- a/frontend/src/data/examples-picow-wifi.ts +++ b/frontend/src/data/examples-picow-wifi.ts @@ -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 ("

Pico W LED: %s

" - " " - "" - "") % 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 ("" + "" + "" + "

Pico W Onboard LED

" + "
%s
" + "" + "" + "") % (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 ("

Pico W Relay: %s

" - " " - "" - "") % state + on = (relay_state == 0) + color = "#22c55e" if on else "#666" + label = "RELAY ON" if on else "RELAY OFF" + return ("" + "" + "

Pico W Relay (GP2)

" + "
%s
" + "" + "" + "") % (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. diff --git a/frontend/src/lib/openDeviceGateway.ts b/frontend/src/lib/openDeviceGateway.ts index 8c31c831..ad0d9754 100644 --- a/frontend/src/lib/openDeviceGateway.ts +++ b/frontend/src/lib/openDeviceGateway.ts @@ -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); }