import { api } from '../api.js'; import { showToast } from '../components/toast.js'; import { esc } from '../utils.js'; import { t } from '../i18n.js'; const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts, }).then(r => r.json()); // Default dimensions for the canvas coordinate space (pixels). Screens added // fresh start at 320x180 (16:9). The editor canvas itself renders at this // natural scale so canvas-pixels == display-pixels. const DEFAULT_SCREEN_W = 320; const DEFAULT_SCREEN_H = 180; const CANVAS_MIN_W = 1200; const CANVAS_MIN_H = 700; const CANVAS_PADDING = 200; // extra room beyond bounding box, in canvas units export async function render(container) { const hash = window.location.hash; if (hash.startsWith('#/wall/')) { const id = hash.split('#/wall/')[1]; return renderWallEditor(container, id); } return renderList(container); } async function renderList(container) { container.innerHTML = `
${t('wall.empty_desc')}
Drag onto the canvas to add. Use the ✕ on a tile to remove.
Click a tile or the player to dial in exact pixel positions.
Arrow keys nudge by 1px. Hold Shift for 10px. Click outside any rect to deselect.
${t('wall.all_assigned')}
`; sidebar.querySelectorAll('[draggable]').forEach(el => { el.addEventListener('dragstart', (e) => { e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'sidebar-device', device_id: el.dataset.deviceId, device_name: el.dataset.deviceName, device_status: el.dataset.deviceStatus, })); }); }); } // Click on canvas background (not on a rect) clears selection canvas.addEventListener('pointerdown', (ev) => { if (ev.target === canvas) { selected = null; applySelectionClasses(); renderSelectionPanel(); } }); // Arrow keys nudge the selected rect by 1px (or 10px with shift). Only // when focus isn't in a text input — typing into the panel's number fields // should still let the browser handle native arrow-key behavior. function onArrowNudge(e) { if (!selected) return; const tag = (e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; let dx = 0, dy = 0; if (e.key === 'ArrowLeft') dx = -1; else if (e.key === 'ArrowRight') dx = 1; else if (e.key === 'ArrowUp') dy = -1; else if (e.key === 'ArrowDown') dy = 1; else return; e.preventDefault(); const step = e.shiftKey ? 10 : 1; const rect = getSelectedRect(); if (!rect) return; rect.x = rect.x + dx * step; rect.y = rect.y + dy * step; const el = selectedDomEl(); if (el) setRectStyle(el, rect); updateOverlapsAll(); updateSelectionInputsFromRect(); markDirty(); } document.addEventListener('keydown', onArrowNudge); cleanupHooks.push(() => document.removeEventListener('keydown', onArrowNudge)); // Canvas accepts sidebar drops to spawn a new screen rect const viewport = document.getElementById('canvasViewport'); // Pan: pointer-drag on empty viewport space (i.e., not on a rect or its // children). The wall-canvas div itself counts as empty. let panState = null; viewport.addEventListener('pointerdown', (ev) => { // Skip if the pointer landed on a rect — that starts drag/resize instead. if (ev.target.closest('.wall-screen, .wall-player')) return; if (ev.button !== 0 && ev.pointerType === 'mouse') return; // Empty-space click also clears selection if (selected) { selected = null; applySelectionClasses(); renderSelectionPanel(); } panState = { px: ev.clientX, py: ev.clientY, ox: pan.x, oy: pan.y, pid: ev.pointerId }; viewport.classList.add('panning'); viewport.setPointerCapture(ev.pointerId); }); viewport.addEventListener('pointermove', (ev) => { if (!panState || ev.pointerId !== panState.pid) return; pan.x = panState.ox + (ev.clientX - panState.px); pan.y = panState.oy + (ev.clientY - panState.py); applyTransform(); }); function endPan(ev) { if (!panState || ev.pointerId !== panState.pid) return; try { viewport.releasePointerCapture(panState.pid); } catch {} panState = null; viewport.classList.remove('panning'); } viewport.addEventListener('pointerup', endPan); viewport.addEventListener('pointercancel', endPan); // Wheel zoom — pivot at cursor so the world point under the cursor stays // pinned. Clamped to a sane range. viewport.addEventListener('wheel', (ev) => { ev.preventDefault(); const vpRect = viewport.getBoundingClientRect(); const cx = ev.clientX - vpRect.left; const cy = ev.clientY - vpRect.top; const worldX = (cx - pan.x) / zoom; const worldY = (cy - pan.y) / zoom; const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1; const newZoom = Math.max(0.1, Math.min(5, zoom * factor)); pan.x = cx - worldX * newZoom; pan.y = cy - worldY * newZoom; zoom = newZoom; applyTransform(); }, { passive: false }); viewport.addEventListener('dragover', (e) => { e.preventDefault(); }); viewport.addEventListener('drop', (e) => { e.preventDefault(); let data; try { data = JSON.parse(e.dataTransfer.getData('text/plain') || '{}'); } catch { return; } if (data.type !== 'sidebar-device' || !data.device_id) return; const vpRect = viewport.getBoundingClientRect(); // Drop pixel → canvas-data coord: undo viewport offset, pan, and zoom. const x = (e.clientX - vpRect.left - pan.x) / zoom - DEFAULT_SCREEN_W / 2; const y = (e.clientY - vpRect.top - pan.y) / zoom - DEFAULT_SCREEN_H / 2; screens.push({ device_id: data.device_id, device_name: data.device_name || 'Display', device_status: data.device_status || 'offline', grid_col: 0, grid_row: 0, rotation: 0, x, y, w: DEFAULT_SCREEN_W, h: DEFAULT_SCREEN_H, }); markDirty(); renderAll(); }); // ---------- Toolbar ---------- document.getElementById('centerViewBtn').addEventListener('click', () => centerView()); document.getElementById('autoArrangeBtn').addEventListener('click', () => { const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 1); const rows = Math.max(1, parseInt(document.getElementById('gridRows').value) || 1); const bH = Math.max(0, parseInt(document.getElementById('bezelH').value) || 0); const bV = Math.max(0, parseInt(document.getElementById('bezelV').value) || 0); const w = DEFAULT_SCREEN_W; const h = DEFAULT_SCREEN_H; let i = 0; for (const s of screens) { if (i >= cols * rows) break; const c = i % cols; const r = Math.floor(i / cols); s.x = c * (w + bH); s.y = r * (h + bV); s.w = w; s.h = h; s.grid_col = c; s.grid_row = r; i++; } // Fit player to whole grid bounding box const b = boundsOf(screens); player.x = b.x; player.y = b.y; player.w = b.w; player.h = b.h; markDirty(); renderAll(); }); document.getElementById('fitPlayerBtn').addEventListener('click', () => { if (screens.length === 0) return; const b = boundsOf(screens); player.x = b.x; player.y = b.y; player.w = b.w; player.h = b.h; markDirty(); renderAll(); }); document.getElementById('saveLayoutBtn').addEventListener('click', async () => { try { // Persist player rect + grid/bezel inputs to the wall, devices to its // member list. Two PUTs because the existing routes are split that way. const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 1); const rows = Math.max(1, parseInt(document.getElementById('gridRows').value) || 1); const bH = Math.max(0, parseInt(document.getElementById('bezelH').value) || 0); const bV = Math.max(0, parseInt(document.getElementById('bezelV').value) || 0); // Quantize all coords to integers before persisting. Drag/resize // produce floats (screen-pixel deltas divided by zoom), and even tiny // FP drift between two screens with the same nominal Y/H produces // visibly different `top`/`height` percentages downstream — a known // source of vertical-misalignment bugs across the wall. await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ grid_cols: cols, grid_rows: rows, bezel_h_mm: bH, bezel_v_mm: bV, player_x: Math.round(player.x), player_y: Math.round(player.y), player_width: Math.round(player.w), player_height: Math.round(player.h), })}); // grid_col/grid_row are kept only to satisfy the legacy // UNIQUE(wall_id, grid_col, grid_row) constraint — render math now uses // canvas_* fields. Synthetic (i, 0) guarantees uniqueness. const payload = screens.map((s, i) => ({ device_id: s.device_id, grid_col: i, grid_row: 0, rotation: s.rotation || 0, canvas_x: Math.round(s.x), canvas_y: Math.round(s.y), canvas_width: Math.round(s.w), canvas_height: Math.round(s.h), })); await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: payload }) }); // Re-fetch master device list so wall_id changes propagate to the sidebar devices = await api.getDevices(); dirty = false; const btn = document.getElementById('saveLayoutBtn'); btn.disabled = true; btn.classList.remove('btn-primary'); showToast('Layout saved', 'success'); } catch (err) { showToast(err.message, 'error'); } }); document.getElementById('renameWallBtn').addEventListener('click', async () => { const newName = prompt('Wall name:', wall.name); if (!newName || newName === wall.name) return; try { await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ name: newName }) }); wall.name = newName; document.getElementById('wallTitleText').textContent = newName; } catch (err) { showToast(err.message, 'error'); } }); document.getElementById('setPlaylistBtn').addEventListener('click', async () => { const playlistId = document.getElementById('wallPlaylist').value || null; try { await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ playlist_id: playlistId }) }); wall.playlist_id = playlistId; showToast(t('wall.toast.playlist_updated') || 'Playlist updated', 'success'); } catch (err) { showToast(err.message, 'error'); } }); document.getElementById('deleteWallBtn').addEventListener('click', async () => { if (!confirm(`Delete wall "${wall.name}"? This returns all displays to ungrouped.`)) return; try { await API(`/walls/${wallId}`, { method: 'DELETE' }); showToast(t('wall.toast.deleted'), 'success'); window.location.hash = '#/walls'; } catch (err) { showToast(err.message, 'error'); } }); // Warn before navigating away with unsaved layout changes function beforeUnloadWarn(e) { if (dirty) { e.preventDefault(); e.returnValue = ''; } } window.addEventListener('beforeunload', beforeUnloadWarn); cleanupHooks.push(() => window.removeEventListener('beforeunload', beforeUnloadWarn)); renderAll(); // Center on initial mount once the viewport has measurable dimensions. // requestAnimationFrame defers until layout settles; fits content + padding. requestAnimationFrame(() => centerView()); // ---------- Internal helpers ---------- function setRectStyle(el, r) { el.style.left = r.x + 'px'; el.style.top = r.y + 'px'; el.style.width = r.w + 'px'; el.style.height = r.h + 'px'; } function attachDragResize(el, rect, onChange) { // Drag the body to move; drag a corner/edge handle to resize. el.addEventListener('pointerdown', (ev) => { // Ignore if clicking the remove button or other inner controls if (ev.target.closest('.wall-screen-remove')) return; const handle = ev.target.closest('.wall-handle'); const dir = handle?.dataset.dir; const mode = dir ? `resize:${dir}` : 'move'; ev.preventDefault(); ev.stopPropagation(); el.setPointerCapture(ev.pointerId); const startX = ev.clientX; const startY = ev.clientY; const start = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }; function move(e) { // Convert screen-pixel deltas to data-pixel deltas via current zoom // so the rect stays under the cursor regardless of zoom level. const dx = (e.clientX - startX) / zoom; const dy = (e.clientY - startY) / zoom; if (mode === 'move') { // Allow negative coords — physical screen layouts can offset above // or to the left of the canvas's notional origin. rect.x = start.x + dx; rect.y = start.y + dy; } else { applyResize(mode.slice(7), dx, dy, start, rect); } onChange(); } function up(e) { el.releasePointerCapture(ev.pointerId); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); onChange(); } el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); }); } } function applyResize(dir, dx, dy, start, rect) { const minW = 40, minH = 24; let { x, y, w, h } = start; if (dir.includes('e')) w = Math.max(minW, start.w + dx); if (dir.includes('s')) h = Math.max(minH, start.h + dy); if (dir.includes('w')) { const newW = Math.max(minW, start.w - dx); x = start.x + (start.w - newW); w = newW; } if (dir.includes('n')) { const newH = Math.max(minH, start.h - dy); y = start.y + (start.h - newH); h = newH; } // x/y unconstrained — negative coords are allowed rect.x = x; rect.y = y; rect.w = w; rect.h = h; } function resizeHandlesHtml() { return ['nw','n','ne','e','se','s','sw','w'] .map(d => ``) .join(''); } function boundsOf(rects) { let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity; for (const r of rects) { if (r.x < x) x = r.x; if (r.y < y) y = r.y; if (r.x + r.w > x2) x2 = r.x + r.w; if (r.y + r.h > y2) y2 = r.y + r.h; } if (!isFinite(x)) return { x: 0, y: 0, w: 0, h: 0 }; return { x, y, w: x2 - x, h: y2 - y }; } function intersect(a, b) { const x = Math.max(a.x, b.x); const y = Math.max(a.y, b.y); const x2 = Math.min(a.x + a.w, b.x + b.w); const y2 = Math.min(a.y + a.h, b.y + b.h); if (x2 <= x || y2 <= y) return null; return { x, y, w: x2 - x, h: y2 - y }; } // Cleanup hooks set during render so we can detach them on view unload. const cleanupHooks = []; export function cleanup() { while (cleanupHooks.length) { try { cleanupHooks.pop()(); } catch {} } }