Files
espresso_frame/server/app/static/frame_layout.js
T
Thomas Faour 3735c5bfa7
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s
Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
2026-07-25 14:21:52 +00:00

347 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <dialog>, 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
// init<Type>Dialog()/close<Type>Dialog() 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,
};
const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
};
let openDialogWidgetType = null;
async function openWidgetDialog(widget) {
const dialogEl = document.getElementById('widget-dialog');
const bodyEl = document.getElementById('widget-dialog-body');
bodyEl.innerHTML = '<p class="sub">Loading...</p>';
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 = `<p class="sub">Could not load: ${e.message}</p>`;
}
}
document.getElementById('widget-dialog-close').addEventListener('click', () => {
document.getElementById('widget-dialog').close();
});
// Native <dialog> 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();