// Layout tab: drag/resize placement canvas for arranging widgets on the // panel, like placing widgets on an Android home screen. Pointer events // (not native HTML5 drag-and-drop, which has known touch // inconsistencies) drive move/resize; every mutation is re-validated // server-side (see routers/api_widgets.py) regardless of what this file // already checked, so after any move/resize/add/remove this just // reloads the canvas from the server's actual state rather than trusting // an optimistic update -- simplest way to guarantee the canvas never // drifts from what a rejected request left in place. // // Widget/canvas geometry is computed and applied in *pixels* from JS, // not CSS percentages/aspect-ratio -- aspect-ratio isn't supported on // every mobile browser this app gets viewed from, and a percentage // height on the widget boxes silently collapses to 0 against an // indeterminate-height ancestor on those browsers (the canvas would // render with no visible size at all, which is exactly what happened // before this was pixel-based). let gridState = null; // last-loaded GET .../widgets response // WIDGET_LABELS comes from common.js (shared with frame_config.js's // button-assignment UI). function renderControlBanner(control) { const banner = document.getElementById('control-banner'); if (!banner) return; if (!control || control.you) { banner.style.display = 'none'; return; } banner.style.display = 'flex'; document.getElementById('control-holder').textContent = control.controller ? `${control.controller} currently has control of this frame.` : 'Nobody has control of this frame yet.'; } async function takeControl() { try { const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'You have control now.'); loadWidgets(); } catch (e) { showStatus(false, e.message); } } document.getElementById('take-control').addEventListener('click', takeControl); // Cached each time the canvas is (re)laid out (see layoutCanvas) so drag // math doesn't re-measure the DOM on every pointermove. let canvasMetrics = { width: 0, height: 0, cellW: 0, cellH: 0 }; function layoutCanvas() { if (!gridState) return; const wrap = document.getElementById('widget-canvas-wrap'); const canvas = document.getElementById('widget-canvas'); const cols = gridState.grid.cols, rows = gridState.grid.rows; // wrap's own width comes from ordinary CSS (100% of the card, capped // at max-width) -- only its height is JS-driven, from that measured // width, to keep the grid's aspect ratio without relying on the CSS // aspect-ratio property. const width = wrap.getBoundingClientRect().width; const height = width * (rows / cols); wrap.style.height = height + 'px'; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; const cellW = width / cols, cellH = height / rows; canvasMetrics = { width, height, cellW, cellH }; wrap.style.backgroundImage = `linear-gradient(to right, var(--border) 1px, transparent 1px),` + `linear-gradient(to bottom, var(--border) 1px, transparent 1px)`; wrap.style.backgroundSize = `${cellW}px ${cellH}px`; for (const box of canvas.children) { positionBox(box, box._rect); } } function positionBox(box, rect) { box._rect = rect; const { cellW, cellH } = canvasMetrics; box.style.left = (rect.x * cellW) + 'px'; box.style.top = (rect.y * cellH) + 'px'; box.style.width = (rect.w * cellW) + 'px'; box.style.height = (rect.h * cellH) + 'px'; } function startDrag(e, widget, box, isResize) { e.preventDefault(); box.setPointerCapture(e.pointerId); const { cellW, cellH } = canvasMetrics; const startX = e.clientX, startY = e.clientY; const orig = { x: widget.x, y: widget.y, w: widget.w, h: widget.h }; const cols = gridState.grid.cols, rows = gridState.grid.rows; const minFootprint = gridState.min_footprint[widget.widget_type] || [1, 1]; let pending = null; box.classList.add('dragging'); function onMove(ev) { const dxCells = Math.round((ev.clientX - startX) / cellW); const dyCells = Math.round((ev.clientY - startY) / cellH); const next = { ...orig }; if (isResize) { next.w = Math.max(minFootprint[0], Math.min(cols - orig.x, orig.w + dxCells)); next.h = Math.max(minFootprint[1], Math.min(rows - orig.y, orig.h + dyCells)); } else { next.x = Math.max(0, Math.min(cols - orig.w, orig.x + dxCells)); next.y = Math.max(0, Math.min(rows - orig.h, orig.y + dyCells)); } pending = next; positionBox(box, next); } function onUp() { box.removeEventListener('pointermove', onMove); box.removeEventListener('pointerup', onUp); box.classList.remove('dragging'); if (pending && (pending.x !== orig.x || pending.y !== orig.y || pending.w !== orig.w || pending.h !== orig.h)) { moveWidget(widget.id, pending); } } box.addEventListener('pointermove', onMove); box.addEventListener('pointerup', onUp); } async function moveWidget(id, rect) { try { const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rect), }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); } catch (e) { showStatus(false, e.message); } finally { // Reload either way: reverts the box to its real position if the // move was rejected (e.g. it would've overlapped another widget), // confirms it otherwise. Simpler and more robust than trying to // separately handle "revert on failure" vs. "confirm on success". loadWidgets(); } } async function removeWidget(id) { if (!confirm('Remove this widget? Its own settings will be lost.')) return; try { const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, { method: 'DELETE' }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Removed.'); } catch (e) { showStatus(false, e.message); } finally { loadWidgets(); } } async function clearAllWidgets() { const count = gridState ? gridState.widgets.length : 0; if (!count) return; const noun = count === 1 ? 'widget' : 'widgets'; if (!confirm(`Remove all ${count} ${noun} from this frame and start over? Their settings will be lost.`)) return; try { const resp = await fetch(`${window.FRAME_API}/widgets`, { method: 'DELETE' }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Cleared.'); } catch (e) { showStatus(false, e.message); } finally { loadWidgets(); } } document.getElementById('clear-all-widgets').addEventListener('click', clearAllWidgets); async function addWidget(widgetType) { try { const resp = await fetch(`${window.FRAME_API}/widgets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ widget_type: widgetType }), }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, `${WIDGET_LABELS[widgetType] || widgetType} widget added.`); } catch (e) { showStatus(false, e.message); } finally { loadWidgets(); } } function renderCanvas() { const canvas = document.getElementById('widget-canvas'); canvas.innerHTML = ''; document.getElementById('widget-canvas-empty-hint').style.display = gridState.widgets.length ? 'none' : ''; document.getElementById('clear-all-widgets').disabled = !gridState.widgets.length; for (const widget of gridState.widgets) { const box = document.createElement('div'); box.className = 'widget-box'; box.dataset.widgetType = widget.widget_type; box._rect = widget; const label = document.createElement('span'); label.className = 'widget-box-label'; label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type; box.appendChild(label); const settingsBtn = document.createElement('button'); settingsBtn.type = 'button'; settingsBtn.className = 'widget-box-settings'; settingsBtn.textContent = '⚙'; settingsBtn.title = `${WIDGET_LABELS[widget.widget_type] || widget.widget_type} settings`; settingsBtn.addEventListener('pointerdown', (e) => e.stopPropagation()); settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); openWidgetDialog(widget); }); box.appendChild(settingsBtn); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'widget-box-remove'; removeBtn.textContent = '×'; removeBtn.title = 'Remove this widget'; removeBtn.addEventListener('pointerdown', (e) => e.stopPropagation()); removeBtn.addEventListener('click', (e) => { e.stopPropagation(); removeWidget(widget.id); }); box.appendChild(removeBtn); const handle = document.createElement('div'); handle.className = 'widget-box-resize-handle'; handle.addEventListener('pointerdown', (e) => { e.stopPropagation(); startDrag(e, widget, box, true); }); box.appendChild(handle); box.addEventListener('pointerdown', (e) => startDrag(e, widget, box, false)); canvas.appendChild(box); } layoutCanvas(); } function renderAddButtons() { const container = document.getElementById('add-widget-buttons'); container.innerHTML = ''; for (const type of gridState.widget_types) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'secondary'; btn.textContent = `+ ${WIDGET_LABELS[type] || type}`; btn.addEventListener('click', () => addWidget(type)); container.appendChild(btn); } const hint = document.getElementById('add-widget-hint'); hint.textContent = 'A new widget is placed in the first open space that fits it -- drag it afterward to reposition.'; } async function loadWidgets() { try { const resp = await fetch(`${window.FRAME_API}/widgets`); if (!resp.ok) throw new Error(await apiError(resp)); gridState = await resp.json(); renderCanvas(); renderAddButtons(); renderControlBanner(gridState.control); } catch (e) { showStatus(false, e.message); } } let resizeTimer = null; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(layoutCanvas, 100); }); // --- gear-icon dialog: each widget's own settings, fetched as an HTML // fragment (routers/frame_pages.py's widget_dialog) and injected into a // single shared , rather than a separate page per widget type -- // a frame can now have several widgets of the same type, so "the // Calendar tab" stopped meaning anything unambiguous. // widget_dialog_{photos,calendar,whiteboard,tasks}.js each define an // initDialog()/closeDialog() pair (loaded unconditionally by // frame_layout.html, since which one runs depends on which widget's gear // icon was clicked). const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog, }; const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog, }; let openDialogWidgetType = null; async function openWidgetDialog(widget) { const dialogEl = document.getElementById('widget-dialog'); const bodyEl = document.getElementById('widget-dialog-body'); bodyEl.innerHTML = '

Loading...

'; dialogEl.querySelector('.dialog-result').innerHTML = ''; // clear any message left over from a previous dialog openDialogWidgetType = widget.widget_type; dialogEl.showModal(); try { const resp = await fetch(`/frames/${window.FRAME_ID}/widgets/${widget.id}/dialog`); if (!resp.ok) throw new Error(await apiError(resp)); bodyEl.innerHTML = await resp.text(); // Every dialog script's fetch calls use window.FRAME_API as their // base -- repointing it at this specific widget (instead of the // frame-level window.FRAME_BASE_API) is what makes the SAME // widget_dialog_photos.js/queue.js/etc. code work correctly no // matter which widget's dialog is currently open. Restored on close. window.FRAME_API = `${window.FRAME_BASE_API}/widgets/${widget.id}`; const init = DIALOG_INIT[widget.widget_type]; if (init) init(); } catch (e) { bodyEl.innerHTML = `

Could not load: ${e.message}

`; } } document.getElementById('widget-dialog-close').addEventListener('click', () => { document.getElementById('widget-dialog').close(); }); // Native doesn't close on backdrop click by default -- a click // that lands outside the dialog's own box (but is still technically // "on" the dialog element, since the backdrop is part of it) counts as // a backdrop click. document.getElementById('widget-dialog').addEventListener('click', (e) => { const dialogEl = e.currentTarget; if (e.target !== dialogEl) return; // click landed on dialog content, not the backdrop const rect = dialogEl.getBoundingClientRect(); const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; if (!inside) dialogEl.close(); }); document.getElementById('widget-dialog').addEventListener('close', () => { const close = DIALOG_CLOSE[openDialogWidgetType]; if (close) close(); openDialogWidgetType = null; window.FRAME_API = window.FRAME_BASE_API; document.getElementById('widget-dialog-body').innerHTML = ''; }); loadWidgets();