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 = `
`; document.getElementById('newWallBtn').onclick = async () => { const name = prompt(t('wall.prompt_name')); if (!name) return; const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) }); window.location.hash = `#/wall/${wall.id}`; }; try { const walls = await API('/walls'); const grid = document.getElementById('wallGrid'); if (!walls.length) { grid.innerHTML = `

${t('wall.empty_title')}

${t('wall.empty_desc')}

`; return; } grid.innerHTML = walls.map(w => `
${Array.from({ length: w.grid_cols * w.grid_rows }, (_, i) => { const row = Math.floor(i / w.grid_cols); const col = i % w.grid_cols; const dev = w.devices?.find(d => d.grid_col === col && d.grid_row === row); return `
${dev?.device_name?.slice(0, 6) || ''}
`; }).join('')}
${w.name}
${t('wall.grid_summary', { cols: w.grid_cols, rows: w.grid_rows, n: w.devices?.length || 0 })}
`).join(''); } catch (err) { showToast(err.message, 'error'); } } // ============================================================ // Free-form canvas wall editor // ============================================================ async function renderWallEditor(container, wallId) { let wall, devices, playlists; try { [wall, devices, playlists] = await Promise.all([ API(`/walls/${wallId}`), api.getDevices(), api.getPlaylists(), ]); } catch { container.innerHTML = `

${t('wall.not_found')}

`; return; } // Local state — server-roundtripped on Save. Backfill from grid math when // canvas_* columns aren't populated (fresh walls or pre-canvas walls). const baseW = DEFAULT_SCREEN_W; const baseH = DEFAULT_SCREEN_H; const bezelH = wall.bezel_h_mm || 0; const bezelV = wall.bezel_v_mm || 0; let screens = (wall.devices || []).map(d => ({ device_id: d.device_id, device_name: d.device_name, device_status: d.device_status, grid_col: d.grid_col, grid_row: d.grid_row, rotation: d.rotation || 0, x: d.canvas_x ?? (d.grid_col * (baseW + bezelH)), y: d.canvas_y ?? (d.grid_row * (baseH + bezelV)), w: d.canvas_width ?? baseW, h: d.canvas_height ?? baseH, })); // Default player covers the bounding box of all screens; if there are no // screens yet, player stays at 0,0 with default screen size. let player; if (wall.player_x !== null && wall.player_x !== undefined) { player = { x: wall.player_x, y: wall.player_y, w: wall.player_width, h: wall.player_height }; } else if (screens.length > 0) { const b = boundsOf(screens); player = { x: b.x, y: b.y, w: b.w, h: b.h }; } else { player = { x: 0, y: 0, w: baseW, h: baseH }; } let dirty = false; function markDirty() { dirty = true; const btn = document.getElementById('saveLayoutBtn'); if (btn) { btn.disabled = false; btn.classList.add('btn-primary'); } } // Selection state for the fine-position panel + arrow-key nudge. One rect // at a time: either a screen (by device_id) or the player. // null when nothing is selected. let selected = null; function getSelectedRect() { if (!selected) return null; if (selected.type === 'player') return player; return screens.find(s => s.device_id === selected.device_id) || null; } function selectScreen(deviceId) { selected = { type: 'screen', device_id: deviceId }; applySelectionClasses(); renderSelectionPanel(); } function selectPlayer() { selected = { type: 'player' }; applySelectionClasses(); renderSelectionPanel(); } function applySelectionClasses() { canvas.querySelectorAll('.selected').forEach(e => e.classList.remove('selected')); if (!selected) return; if (selected.type === 'player') canvas.querySelector('.wall-player')?.classList.add('selected'); else { const el = canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(selected.device_id)}"]`); if (el) el.classList.add('selected'); } } function getUnassigned() { const inThisWall = new Set(screens.map(s => s.device_id)); return devices.filter(d => !d.wall_id && !inThisWall.has(d.id)); } container.innerHTML = ` ${t('wall.back')}
100%
Cols/rows/bezel are used by Auto-arrange. Drag freely on the canvas to override.

${t('wall.playlist') || 'Playlist'}

${t('wall.available_displays')}

Drag onto the canvas to add. Use the ✕ on a tile to remove.

How it works
  • Each rectangle is a physical screen.
  • The blue dashed rectangle is the player window — content plays inside this rect.
  • Each screen shows only the part of the player that overlaps it.
  • Drag corners to resize, drag the body to move.
`; const canvas = document.getElementById('wallCanvas'); function renderAll() { canvas.innerHTML = ''; canvas.appendChild(renderPlayerEl()); for (const s of screens) canvas.appendChild(renderScreenEl(s)); updateOverlapsAll(); renderSidebar(); applySelectionClasses(); renderSelectionPanel(); applyTransform(); } // Render the fine-position panel: numeric x/y/w/h inputs for the selected // rect plus the arrow-key hint. Two-way bound — typing into inputs moves // the rect; dragging the rect updates the inputs in place (without // rebuilding the DOM, so focus survives a drag). function renderSelectionPanel() { const panel = document.getElementById('selectionPanel'); if (!panel) return; const rect = getSelectedRect(); if (!rect) { panel.innerHTML = `
Fine position

Click a tile or the player to dial in exact pixel positions.

`; return; } const isPlayer = selected.type === 'player'; const label = isPlayer ? 'Player rect' : (rect.device_name || 'Screen'); panel.innerHTML = `
${esc(label)}

Arrow keys nudge by 1px. Hold Shift for 10px. Click outside any rect to deselect.

`; panel.querySelector('#deselectBtn').addEventListener('click', () => { selected = null; applySelectionClasses(); renderSelectionPanel(); }); panel.querySelectorAll('input[data-field]').forEach(input => { input.addEventListener('input', () => { const v = parseFloat(input.value); if (!isFinite(v)) return; const f = input.dataset.field; const r = getSelectedRect(); if (!r) return; if (f === 'w') r.w = Math.max(40, v); else if (f === 'h') r.h = Math.max(24, v); else r[f] = v; // x/y can be negative const el = selectedDomEl(); if (el) setRectStyle(el, r); updateOverlapsAll(); markDirty(); // Don't rebuild this panel — keeps the input focused. }); }); } function selectedDomEl() { if (!selected) return null; if (selected.type === 'player') return canvas.querySelector('.wall-player'); return canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(selected.device_id)}"]`); } // Sync the panel inputs to the rect's current values without rebuilding // the DOM (so focus survives a drag-resize). Called from drag onChange. function updateSelectionInputsFromRect() { if (!selected) return; const rect = getSelectedRect(); if (!rect) return; const panel = document.getElementById('selectionPanel'); if (!panel) return; for (const f of ['x','y','w','h']) { const input = panel.querySelector(`input[data-field="${f}"]`); if (input && document.activeElement !== input) input.value = Math.round(rect[f]); } } // Pan/zoom state. pan is in viewport screen pixels; zoom is unitless. // The canvas div is a 0×0 anchor; its CSS transform supplies the mapping // from data coords to viewport pixels. All rect children inherit it. let pan = { x: 0, y: 0 }; let zoom = 1; function applyTransform() { canvas.style.transform = `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`; const r = document.getElementById('zoomReadout'); if (r) r.textContent = Math.round(zoom * 100) + '%'; } // Re-center bounds in the viewport with a small zoom-out so there's slack // around the content for dragging into. Capped at 1× so we never zoom *in* // beyond natural scale on a small layout. function centerView() { const viewport = document.getElementById('canvasViewport'); if (!viewport) return; const all = screens.length > 0 ? [...screens, player] : [player]; const b = boundsOf(all); const vw = viewport.clientWidth, vh = viewport.clientHeight; if (!b.w || !b.h) { pan = { x: vw / 2, y: vh / 2 }; zoom = 1; } else { const fitX = (vw * 0.75) / b.w; const fitY = (vh * 0.75) / b.h; zoom = Math.max(0.1, Math.min(1, fitX, fitY)); pan.x = vw / 2 - (b.x + b.w / 2) * zoom; pan.y = vh / 2 - (b.y + b.h / 2) * zoom; } applyTransform(); } function renderScreenEl(s) { const el = document.createElement('div'); el.className = 'wall-screen'; el.dataset.deviceId = s.device_id; setRectStyle(el, s); el.innerHTML = `
${esc(s.device_name)}
${Math.round(s.w)}×${Math.round(s.h)}
${resizeHandlesHtml()} `; el.querySelector('.wall-screen-remove').addEventListener('click', (ev) => { ev.stopPropagation(); screens = screens.filter(x => x.device_id !== s.device_id); if (selected?.type === 'screen' && selected.device_id === s.device_id) selected = null; markDirty(); renderAll(); }); el.addEventListener('pointerdown', (ev) => { if (ev.target.closest('.wall-screen-remove')) return; selectScreen(s.device_id); }); attachDragResize(el, s, () => { setRectStyle(el, s); const meta = el.querySelector('.wall-screen-meta span:last-child'); if (meta) meta.textContent = `${Math.round(s.w)}×${Math.round(s.h)}`; updateOverlapsAll(); updateSelectionInputsFromRect(); markDirty(); }); return el; } function renderPlayerEl() { const el = document.createElement('div'); el.className = 'wall-player'; setRectStyle(el, player); el.innerHTML = `
PLAYER ${Math.round(player.w)}×${Math.round(player.h)}
${resizeHandlesHtml()} `; el.addEventListener('pointerdown', () => selectPlayer()); attachDragResize(el, player, () => { setRectStyle(el, player); const meta = el.querySelector('.wall-player-label span:last-child'); if (meta) meta.textContent = `${Math.round(player.w)}×${Math.round(player.h)}`; updateOverlapsAll(); updateSelectionInputsFromRect(); markDirty(); }); return el; } function updateOverlapsAll() { canvas.querySelectorAll('.wall-screen').forEach(el => { const id = el.dataset.deviceId; const s = screens.find(x => x.device_id === id); if (!s) return; const ov = el.querySelector('.wall-screen-overlap'); const inter = intersect(s, player); if (!inter) { ov.style.display = 'none'; return; } ov.style.display = 'block'; ov.style.left = (inter.x - s.x) + 'px'; ov.style.top = (inter.y - s.y) + 'px'; ov.style.width = inter.w + 'px'; ov.style.height = inter.h + 'px'; }); } function renderSidebar() { const sidebar = document.getElementById('availableDevices'); const unassigned = getUnassigned(); sidebar.innerHTML = unassigned.length ? unassigned.map(d => `
${esc(d.name)}
${d.status}
`).join('') : `

${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 {} } }