diff --git a/www/_timer_theme.php b/www/_timer_theme.php index 0fe264d..2502db2 100644 --- a/www/_timer_theme.php +++ b/www/_timer_theme.php @@ -22,6 +22,7 @@ function timer_theme_defaults(): array { 'rebuys' => ['visible'=>true,'color'=>'#94a3b8','scale'=>1.0], 'chips_in_play' => ['visible'=>true,'color'=>'#94a3b8','scale'=>1.0], 'next_break' => ['visible'=>true,'color'=>'#94a3b8','scale'=>1.0], + 'ends_at' => ['visible'=>true,'color'=>'#94a3b8','scale'=>1.0], 'streaming' => ['visible'=>false,'scale'=>1.0,'url'=>''], ], 'tray' => ['bg_color'=>'#1e293b','button_color'=>'#e2e8f0','accent_color'=>'#2563eb'], @@ -116,6 +117,7 @@ function timer_theme_css_vars(array $props): string { '--timer-rebuys-color' => $el['rebuys']['color'] ?? '#94a3b8', '--timer-chips-color' => $el['chips_in_play']['color'] ?? '#94a3b8', '--timer-nextbreak-color' => $el['next_break']['color'] ?? '#94a3b8', + '--timer-endsat-color' => $el['ends_at']['color'] ?? '#94a3b8', '--timer-tray-button-bg' => $tray['bg_color'] ?? '#1e293b', '--timer-tray-button-color' => $tray['button_color'] ?? '#e2e8f0', '--timer-accent' => $tray['accent_color'] ?? '#2563eb', @@ -140,45 +142,9 @@ function timer_theme_css_vars(array $props): string { } $css .= "}\n"; - // Element visibility — emit `display:none` for hidden elements so first paint matches. - $visMap = [ - 'event_name' => '.timer-event-name', - 'player_count' => '#playerWrap', - 'pool_total' => '#poolWrap', - 'level_label' => '.timer-level-label', - 'blinds' => '.timer-blinds', - 'clock' => '.timer-clock', - 'paused_label' => '#pausedLabel', - 'next_level' => '.timer-next', - 'avg_stack' => '#avgStackWrap', - 'payouts' => '#payoutsWrap', - 'qr' => '#qrWrap', - 'image' => '#themeImage', - 'rebuys' => '#rebuysWrap', - 'chips_in_play' => '#chipsInPlayWrap', - 'next_break' => '#nextBreakWrap', - 'streaming' => '#streamingWrap', - ]; - foreach ($visMap as $key => $sel) { - $visible = $el[$key]['visible'] ?? true; - if (!$visible) { - $css .= "{$sel} { display: none !important; }\n"; - } - } - - // Order — emit CSS `order` for the four main display elements. - $orderMap = [ - 'level_label' => '.timer-level-label', - 'blinds' => '.timer-blinds', - 'clock' => '.timer-clock', - 'next_level' => '.timer-next', - ]; - foreach ($orderMap as $key => $sel) { - $ord = (int)($el[$key]['order'] ?? 0); - if ($ord > 0) { - $css .= "{$sel} { order: {$ord}; }\n"; - } - } + // Visibility and flex-order are JS-owned (syncVisibility / applyTheme in + // timer.php). Emitting them here too is the parallel-path pattern that let + // ends_at drift; the theme-pending gate on covers first paint instead. return $css; } diff --git a/www/timer.php b/www/timer.php index b7e6513..0e138bd 100644 --- a/www/timer.php +++ b/www/timer.php @@ -269,7 +269,7 @@ $themeCss = timer_theme_css_vars($themeProps); ?> - + @@ -278,6 +278,11 @@ + @@ -1414,11 +1612,22 @@
+ + + + + + + + + + +
@@ -1470,7 +1679,23 @@ -
+
+ + +
+ + +
+
+
+
+
+ +
+
+
+
'; - } - payBody.innerHTML = h; - payWrap.style.display = ''; - } else { - payWrap.style.display = _inEdit ? '' : 'none'; - if (_inEdit && !payBody.innerHTML.trim()) { - payBody.innerHTML = '
1st: $0.00 (50%)
' - + '
2nd: $0.00 (30%)
' - + '
3rd: $0.00 (20%)
'; - } + if (GAME_TYPE === 'tournament' && PAYOUTS && PAYOUTS.length > 0 && POOL && POOL.pool_total > 0) { + var h = ''; + var ordinals = ['1st','2nd','3rd','4th','5th','6th','7th','8th','9th','10th']; + for (var i = 0; i < PAYOUTS.length; i++) { + var pct = parseFloat(PAYOUTS[i].percentage) || 0; + var amt = Math.round(POOL.pool_total * pct / 100); + // Reward suffixes (points / entry ticket / prize label) ride in + // the same themeable row as the cash amount. + var extra = ''; + if (parseInt(PAYOUTS[i].points) > 0) extra += ' · ' + parseInt(PAYOUTS[i].points) + 'pts'; + if (parseInt(PAYOUTS[i].ticket_cents) > 0) extra += ' · 🎟' + fmtMoney(parseInt(PAYOUTS[i].ticket_cents)); + if (PAYOUTS[i].prize_label) extra += ' · ' + String(PAYOUTS[i].prize_label).replace(/&/g,'&').replace(/' + fmtMoney(amt) + '' + (pct > 0 ? ' (' + pct + '%)' : '') + extra + '
'; + } + setHtml(payBody, h); + DATA_AVAIL.payouts = true; + } else { + DATA_AVAIL.payouts = false; + if (_inEdit && payBody && !payBody.innerHTML.trim()) { + // Realistic widths — the user positions against believable content. + setHtml(payBody, '
1st: $250.00 (50%)
' + + '
2nd: $150.00 (30%)
' + + '
3rd: $100.00 (20%)
'); } } // Paused label — show "PAUSED" placeholder while in edit mode so it can be themed. - el('pausedLabel').textContent = (_inEdit || !TIMER.is_running) ? 'PAUSED' : ''; + setText(el('pausedLabel'), (_inEdit || !TIMER.is_running) ? 'PAUSED' : ''); + + syncVisibility(); + if (window._contentDirty) { window._contentDirty = false; scheduleFit(); } } // Renderer registry — keyed by element key, then by variant name. @@ -2215,7 +2517,13 @@ function renderClock() { function renderClockText(node, secs) { var s = fmtTime(secs); - if (node.textContent !== s) node.textContent = s; + if (node.textContent !== s) { + // Refit only when the string LENGTH changes (1:00:00 → 59:59) — the + // once-a-second same-width tick must not re-run measurement. + var lenChanged = (node.textContent || '').length !== s.length; + node.textContent = s; + if (lenChanged) scheduleFit(); + } } // SVG arc-path helper used by the radial-checks variant. @@ -3861,21 +4169,17 @@ function applyTheme(props) { bg.type = 'color'; props.elements = el; } - // Apply the image element (src + scale). + // Apply the image element (src + scale). Visibility is syncVisibility()'s job; + // here we only manage the src and record whether there is anything to show. var imgNode = document.getElementById('themeImage'); if (imgNode) { - if (el.image && el.image.url && el.image.visible !== false) { + if (el.image && el.image.url) { if (imgNode.getAttribute('src') !== el.image.url) imgNode.setAttribute('src', el.image.url); - imgNode.style.display = ''; - imgNode.style.setProperty('--timer-image-scale', String(el.image.scale || 1)); - } else if (el.image && el.image.url && el.image.visible === false) { - // Theme-hidden: keep src but let the standard visibility loop ghost/hide it. - if (imgNode.getAttribute('src') !== el.image.url) imgNode.setAttribute('src', el.image.url); - imgNode.style.display = ''; imgNode.style.setProperty('--timer-image-scale', String(el.image.scale || 1)); + DATA_AVAIL.image = true; } else { imgNode.removeAttribute('src'); - imgNode.style.display = 'none'; + DATA_AVAIL.image = false; } } @@ -3900,12 +4204,14 @@ function applyTheme(props) { var ph = document.getElementById('streamingPlaceholder'); if (ph) ph.remove(); delete streamWrap.dataset.placeholderSet; - streamWrap.style.display = (s.visible === false && !inEditNow) ? 'none' : ''; + DATA_AVAIL.streaming = true; } else { - // No URL — clear iframe src so nothing autoplays. + // No URL — clear iframe src so nothing autoplays. The wrapper itself is + // hidden (or edit-force-shown with this placeholder) by syncVisibility. streamFrame.removeAttribute('src'); + DATA_AVAIL.streaming = false; if (inEditNow) { - // Show a labeled placeholder inside the wrapper so the user can see and click it. + // Labeled placeholder inside the wrapper so the user can see and click it. streamFrame.style.display = 'none'; streamWrap.classList.add('is-empty'); if (!streamWrap.dataset.placeholderSet) { @@ -3916,10 +4222,8 @@ function applyTheme(props) { streamWrap.appendChild(label); streamWrap.dataset.placeholderSet = '1'; } - streamWrap.style.display = ''; } else { streamWrap.classList.remove('is-empty'); - streamWrap.style.display = 'none'; } } } @@ -3928,35 +4232,7 @@ function applyTheme(props) { root.setProperty('--timer-tray-button-color', tray.button_color || '#e2e8f0'); root.setProperty('--timer-accent', tray.accent_color || '#2563eb'); - // Visibility — hidden elements are truly hidden, even in edit mode. They only - // ghost on canvas while currently selected (so the user can position them). This - // moves the "where is my hidden element?" discovery into the Objects panel rather - // than ghosting every hidden object on screen. - var inEdit = document.body.classList.contains('layout-edit'); - var selSet = (typeof LAYOUT_SELECTION_SET !== 'undefined') ? LAYOUT_SELECTION_SET : null; - for (var k in THEME_SELECTORS) { - var node = document.querySelector(THEME_SELECTORS[k]); - if (!node) continue; - var visible = el[k] && el[k].visible !== false; - var isSelected = inEdit && selSet && selSet.has && selSet.has(k); - if (!visible) { - node.dataset._themeHidden = '1'; - if (isSelected) { - node.style.display = ''; - node.style.opacity = '0.45'; - node.dataset.ghostSelected = '1'; - } else { - node.style.display = 'none'; - node.style.opacity = ''; - delete node.dataset.ghostSelected; - } - } else if (node.dataset._themeHidden === '1') { - delete node.dataset._themeHidden; - delete node.dataset.ghostSelected; - node.style.display = ''; - node.style.opacity = ''; - } - } + syncVisibility(); // Order ['level_label','blinds','clock','next_level'].forEach(function(k){ @@ -3966,21 +4242,68 @@ function applyTheme(props) { if (ord > 0) node.style.order = String(ord); }); - // Free-form positions: any element with elements[key].pos = {x,y} gets pulled out of - // flow and pinned to (x%, y%) of the viewport, anchored at the element's center. + // Zones-vs-free structural layout (reparenting + grid class) happens first, + // then per-element geometry. + if (typeof applyLayout === 'function') applyLayout(props); + + // Free-form geometry. Preference order per element: + // 1. v2 box {x,y,w,h} (% of #layoutStage, top-left anchored) — .timer-boxed + // 2. legacy center point pos {x,y} (% of viewport) — .timer-positioned + // 3. neither — element stays in flex flow (or its zone, in zones mode) + var zonesActive = props.mode === 'zones'; + var _lo = currentLayoutOrientationKey(props); + var freeMap = (props.layouts && props.layouts[_lo] && props.layouts[_lo].free) || null; for (var k2 in THEME_SELECTORS) { var node2 = document.querySelector(THEME_SELECTORS[k2]); if (!node2) continue; var pe = el[k2]; - var pos = (pe && pe.pos && typeof pe.pos.x === 'number' && typeof pe.pos.y === 'number') ? pe.pos : null; - if (pos) { + var box = (!zonesActive && freeMap && validBox(freeMap[k2])) ? freeMap[k2] : null; + var pos = (!zonesActive && !box && pe && pe.pos && typeof pe.pos.x === 'number' && typeof pe.pos.y === 'number') ? pe.pos : null; + if (box) { + node2.classList.add('timer-boxed'); + node2.classList.remove('timer-positioned'); + applyBoxToNode(node2, box); + node2.style.removeProperty('--pos-x'); + node2.style.removeProperty('--pos-y'); + node2.style.maxWidth = ''; + node2.style.maxHeight = ''; + // Transform-scaled widgets: in box mode the box is the size authority, + // so the scale becomes a font multiplier (fit-text reads boxScale) + // instead of a transform that would escape the box. + if (node2.dataset.hasScale === '1') { + delete node2.dataset.hasScale; + node2.dataset.boxScale = String((pe && pe.scale) || 1); + } + } else if (pos) { + node2.classList.remove('timer-boxed'); + delete node2.dataset.boxScale; node2.classList.add('timer-positioned'); node2.style.setProperty('--pos-x', pos.x + '%'); node2.style.setProperty('--pos-y', pos.y + '%'); + // Soft extent caps: a center-anchored element can extend at most + // twice the distance to its nearest viewport edge before hanging + // off-screen. Fit-text shrinks content into this cap, so legacy + // point-positioned themes stop overflowing with no stored changes. + // Elements scaled via transform (data-has-scale) are laid out + // pre-transform, so their cap shrinks by the scale factor to keep + // the VISUAL box at the edge. + var _capScl = (node2.dataset.hasScale === '1' && pe && pe.scale) ? (parseFloat(pe.scale) || 1) : 1; + node2.style.maxWidth = (Math.max(4, 2 * Math.min(pos.x, 100 - pos.x)) / _capScl) + 'vw'; + node2.style.maxHeight = (Math.max(3, 2 * Math.min(pos.y, 100 - pos.y)) / _capScl) + 'vh'; } else { node2.classList.remove('timer-positioned'); + node2.classList.remove('timer-boxed'); + delete node2.dataset.boxScale; node2.style.removeProperty('--pos-x'); node2.style.removeProperty('--pos-y'); + node2.style.maxWidth = ''; + node2.style.maxHeight = ''; + // Zone items: transform scale doesn't apply outside .timer-positioned, + // so per-element scale rides the font multiplier instead. + if (zonesActive && node2.dataset.hasScale === '1') { + delete node2.dataset.hasScale; + node2.dataset.boxScale = String((pe && pe.scale) || 1); + } } // Per-element stacking. When z_index is set (after the user restacks via // the Objects panel) it overrides the stylesheet default — including the @@ -3994,13 +4317,88 @@ function applyTheme(props) { // Variant / thickness changes from the inspector mutate the theme but don't change // the next tick's text — force a clock re-render so visual feedback is instant. if (typeof renderClock === 'function') renderClock(); + scheduleFit(); + if (typeof LAYOUT_EDIT_ON !== 'undefined' && LAYOUT_EDIT_ON && typeof syncRzOverlay === 'function') syncRzOverlay(); +} + +// ─── §7.15.0 syncVisibility — the ONE writer of style.display ─── +// Combines theme visibility (elements[key].visible) with data availability +// (DATA_AVAIL, written by renderAll/applyTheme). Widgets in EDIT_FORCE_KEYS are +// shown in layout-edit mode even without data (renderAll seeds placeholder +// text) so they can be positioned. Theme-hidden elements are truly hidden and +// only ghost at 45% opacity while selected on the edit canvas — discovery of +// hidden elements lives in the Objects panel. +var EDIT_FORCE_KEYS = { avg_stack:1, rebuys:1, chips_in_play:1, next_break:1, ends_at:1, payouts:1, streaming:1 }; + +function syncVisibility() { + var theme = window.TIMER_THEME || {}; + var el = theme.elements || {}; + var inEdit = document.body.classList.contains('layout-edit'); + var selSet = (typeof LAYOUT_SELECTION_SET !== 'undefined') ? LAYOUT_SELECTION_SET : null; + // Zones mode: elements without a zone assignment don't render (the Objects + // panel lists them under "Unplaced" with an add-to-zone control). + var zonesOn = theme.mode === 'zones'; + var zAssign = null; + if (zonesOn) { + var _so = currentLayoutOrientationKey(theme); + var zc = (theme.layouts && theme.layouts[_so] && theme.layouts[_so].zones) || {}; + zAssign = zc.assign || {}; + } + for (var k in THEME_SELECTORS) { + var node = document.querySelector(THEME_SELECTORS[k]); + if (!node) continue; + var themeVisible = !el[k] || el[k].visible !== false; + // Live preview: skip the edit-mode force-show and honor real data + // availability, so the user positions against what actually renders. + var dataOk = DATA_AVAIL[k] !== false || (inEdit && !window.LIVE_PREVIEW && EDIT_FORCE_KEYS[k]); + var placed = !zonesOn || !!(zAssign && zAssign[k] && ZONE_NAMES[zAssign[k].zone]); + var isSelected = inEdit && selSet && selSet.has && selSet.has(k); + if (!themeVisible || !placed) { + if (!themeVisible) node.dataset._themeHidden = '1'; + // Theme-hidden elements ghost at 45% while selected on the canvas; + // unplaced elements (zones mode) are always fully hidden. + if (isSelected && !themeVisible && placed) { + node.style.display = ''; + node.style.opacity = '0.45'; + node.dataset.ghostSelected = '1'; + } else { + node.style.display = 'none'; + node.style.opacity = ''; + delete node.dataset.ghostSelected; + } + } else { + delete node.dataset._themeHidden; + delete node.dataset.ghostSelected; + node.style.display = dataOk ? '' : 'none'; + node.style.opacity = ''; + } + } + if (zonesOn && typeof updateZoneCollapse === 'function') updateZoneCollapse(); } // Build a deep-cloned theme payload from the current in-memory state. With the modal // slimmed down to a pure library, all element/bg/tray edits flow through the in-place // inspector (which mutates window.TIMER_THEME directly), so we can just return a copy. +// v2 boxes additionally mirror their centers into the legacy pos fields for one +// release, so a long-lived open page running v1 JS still positions roughly right. function readThemeFromUI() { - return JSON.parse(JSON.stringify(window.TIMER_THEME || {})); + var t = JSON.parse(JSON.stringify(window.TIMER_THEME || {})); + var free = t.layouts && t.layouts.landscape && t.layouts.landscape.free; + if (free) { + t.schema = 2; + if (t.mode !== 'zones') t.mode = 'free'; + t.elements = t.elements || {}; + Object.keys(free).forEach(function(k) { + var b = free[k]; + if (!b || typeof b.x !== 'number' || typeof b.w !== 'number') return; + t.elements[k] = t.elements[k] || {}; + t.elements[k].pos = { + x: Math.round((b.x + b.w / 2) * 100) / 100, + y: Math.round((b.y + b.h / 2) * 100) / 100, + }; + }); + } + return t; } function openThemes() { @@ -4437,6 +4835,234 @@ function setAsDefaultTheme() { streaming: { x: 75, y: 30 }, }; +// ─── v2 box layout: {x,y,w,h} top-left anchored, % of #layoutStage ─── +// Heuristic seeds for elements that can't be measured (hidden / zero-size); +// measured seeds from the live DOM are always preferred (seedBoxes). +var BOX_DEFAULTS = { + event_name: { x: 20, y: 1, w: 60, h: 7 }, + player_count: { x: 28, y: 9, w: 14, h: 5 }, + pool_total: { x: 58, y: 9, w: 14, h: 5 }, + level_label: { x: 35, y: 16, w: 30, h: 8 }, + blinds: { x: 20, y: 26, w: 60, h: 18 }, + clock: { x: 25, y: 46, w: 50, h: 26 }, + paused_label: { x: 35, y: 74, w: 30, h: 6 }, + next_level: { x: 25, y: 82, w: 50, h: 9 }, + avg_stack: { x: 1, y: 10, w: 14, h: 10 }, + payouts: { x: 85, y: 10, w: 14, h: 22 }, + qr: { x: 89, y: 82, w: 10, h: 16 }, + image: { x: 35, y: 35, w: 30, h: 30 }, + rebuys: { x: 24, y: 2, w: 12, h: 5 }, + chips_in_play: { x: 44, y: 2, w: 12, h: 5 }, + next_break: { x: 64, y: 2, w: 12, h: 5 }, + ends_at: { x: 64, y: 8, w: 12, h: 5 }, + streaming: { x: 62, y: 16, w: 30, h: 28 }, +}; + +// ─── Per-orientation layouts: a theme carries layouts.landscape and +// layouts.portrait; the display renders the one matching the viewport and +// falls back to the other when it's empty. The editor can pin an orientation +// (EDIT_ORIENTATION) and letterbox the stage to simulate it. ─── +var EDIT_ORIENTATION = null; // editor override; null = follow the viewport + +function getActiveOrientation() { + try { return window.matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape'; } + catch (e) { return window.innerHeight > window.innerWidth ? 'portrait' : 'landscape'; } +} + +function otherOrientation(o) { return o === 'portrait' ? 'landscape' : 'portrait'; } + +function hasLayoutContent(l) { + if (!l) return false; + if (l.free && Object.keys(l.free).length) return true; + if (l.zones && l.zones.assign && Object.keys(l.zones.assign).length) return true; + return false; +} + +// Which orientation's layout should RENDER right now. +function resolveLayoutOrientation(props) { + var o = getActiveOrientation(); + var L = props && props.layouts; + if (!L) return o; + if (hasLayoutContent(L[o])) return o; + if (hasLayoutContent(L[otherOrientation(o)])) return otherOrientation(o); + return o; +} + +// The orientation every reader/writer targets: the editor's pin wins in edit +// mode; otherwise the resolved display orientation. +function currentLayoutOrientationKey(props) { + if (typeof LAYOUT_EDIT_ON !== 'undefined' && LAYOUT_EDIT_ON && EDIT_ORIENTATION) return EDIT_ORIENTATION; + return resolveLayoutOrientation(props || window.TIMER_THEME); +} + +// Accessor for the target orientation's free-box map. +function activeLayoutFree(create) { + var t = window.TIMER_THEME; + if (!t) return null; + var o = currentLayoutOrientationKey(t); + if (!create) { + return (t.layouts && t.layouts[o] && t.layouts[o].free) || null; + } + t.layouts = t.layouts || {}; + t.layouts[o] = t.layouts[o] || {}; + t.layouts[o].free = t.layouts[o].free || {}; + t.schema = 2; + if (t.mode !== 'zones') t.mode = 'free'; + return t.layouts[o].free; +} + +function validBox(b) { + return b && typeof b.x === 'number' && typeof b.y === 'number' + && typeof b.w === 'number' && typeof b.h === 'number' + && b.w > 0 && b.h > 0 && b.x >= -1 && b.y >= -1 && b.x + b.w <= 101 && b.y + b.h <= 101; +} + +function stageRect() { + var s = document.getElementById('layoutStage'); + return s ? s.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; +} + +// Write a box to both the theme and the element's inline vars (drag/resize hot path). +function applyBoxToNode(node, b) { + node.style.setProperty('--box-x', b.x + '%'); + node.style.setProperty('--box-y', b.y + '%'); + node.style.setProperty('--box-w', b.w + '%'); + node.style.setProperty('--box-h', b.h + '%'); +} + +// ─── Zones mode: fixed 5-zone grid, elements assigned per zone with an order ─── +var ZONE_NAMES = { top: 1, left: 1, center: 1, right: 1, bottom: 1 }; +var ZONE_LIST = ['top', 'left', 'center', 'right', 'bottom']; +var ZONE_DEFAULT_SIZE = { top: 10, left: 14, right: 14, bottom: 8 }; + +// Mirrors today's flow layout, so switching a theme to zones is a familiar start. +var DEFAULT_ZONE_ASSIGN = { + event_name: { zone: 'top', order: 10 }, + player_count: { zone: 'top', order: 20 }, + pool_total: { zone: 'top', order: 30 }, + rebuys: { zone: 'top', order: 40 }, + chips_in_play: { zone: 'top', order: 50 }, + next_break: { zone: 'top', order: 60 }, + ends_at: { zone: 'top', order: 70 }, + avg_stack: { zone: 'left', order: 10 }, + level_label: { zone: 'center', order: 10 }, + blinds: { zone: 'center', order: 20 }, + clock: { zone: 'center', order: 30 }, + paused_label: { zone: 'center', order: 40 }, + next_level: { zone: 'center', order: 50 }, + image: { zone: 'center', order: 60 }, + payouts: { zone: 'right', order: 10 }, + streaming: { zone: 'right', order: 20 }, + qr: { zone: 'bottom', order: 10 }, +}; + +function activeLayoutZones(create) { + var t = window.TIMER_THEME; + if (!t) return null; + var o = currentLayoutOrientationKey(t); + if (!create) { + return (t.layouts && t.layouts[o] && t.layouts[o].zones) || null; + } + t.layouts = t.layouts || {}; + t.layouts[o] = t.layouts[o] || {}; + var z = t.layouts[o].zones = t.layouts[o].zones || {}; + z.opts = z.opts || {}; + z.assign = z.assign || {}; + t.schema = 2; + return z; +} + +// Where each element natively lives in the flow markup — captured once so +// leaving zones mode can put every node back exactly where it came from. +var ORIGINAL_SLOTS = null; +function captureOriginalSlots() { + if (ORIGINAL_SLOTS) return; + ORIGINAL_SLOTS = {}; + for (var k in THEME_SELECTORS) { + var n = document.querySelector(THEME_SELECTORS[k]); + if (!n) continue; + ORIGINAL_SLOTS[k] = { parent: n.parentNode, next: n.nextSibling }; + } +} + +// Minimal-move ordered reparenting: nodes already in place are not touched, +// so the streaming iframe never reloads from an unrelated reassignment. +function syncChildren(container, desiredNodes) { + var ref = container.firstElementChild; + desiredNodes.forEach(function (n) { + if (n === ref) { ref = ref.nextElementSibling; return; } + container.insertBefore(n, ref); + }); +} + +function applyLayout(props) { + captureOriginalSlots(); + var zonesOn = props && props.mode === 'zones'; + document.body.classList.toggle('layout-zones', !!zonesOn); + var stage = document.getElementById('layoutStage'); + if (!zonesOn) { + // Restore every element to its native flow slot (no-op when untouched). + for (var k in THEME_SELECTORS) { + var slot = ORIGINAL_SLOTS[k]; + var n = document.querySelector(THEME_SELECTORS[k]); + if (!slot || !n || !slot.parent) continue; + if (n.parentNode !== slot.parent) slot.parent.insertBefore(n, slot.next && slot.next.parentNode === slot.parent ? slot.next : null); + } + return; + } + var _zo = currentLayoutOrientationKey(props); + var zc = (props.layouts && props.layouts[_zo] && props.layouts[_zo].zones) || {}; + var assign = zc.assign || {}; + var opts = zc.opts || {}; + // Per-zone options → CSS vars/styles. + ZONE_LIST.forEach(function (z) { + var zn = document.querySelector('#layoutStage .gn-zone[data-zone="' + z + '"]'); + if (!zn) return; + var o = opts[z] || {}; + var align = { start: 'flex-start', center: 'center', end: 'flex-end' }[o.align] || 'center'; + zn.style.setProperty('--zone-align', align); + zn.style.setProperty('--zone-gap', String(Math.max(0, Math.min(8, parseFloat(o.gap) || 2)))); + }); + // Group assigned elements by zone, sorted by order, and reparent. + var byZone = {}; + Object.keys(assign).forEach(function (k) { + var a = assign[k]; + if (!a || !ZONE_NAMES[a.zone] || !THEME_SELECTORS[k]) return; + (byZone[a.zone] = byZone[a.zone] || []).push({ k: k, order: parseInt(a.order, 10) || 0 }); + }); + ZONE_LIST.forEach(function (z) { + var zn = document.querySelector('#layoutStage .gn-zone[data-zone="' + z + '"]'); + if (!zn) return; + var nodes = (byZone[z] || []) + .sort(function (a, b) { return a.order - b.order; }) + .map(function (it) { return document.querySelector(THEME_SELECTORS[it.k]); }) + .filter(Boolean); + syncChildren(zn, nodes); + }); + updateZoneCollapse(); +} + +// Empty zones collapse to zero outside edit mode; in edit mode they keep their +// configured size so there's somewhere to assign elements into. +function updateZoneCollapse() { + var stage = document.getElementById('layoutStage'); + if (!stage || !document.body.classList.contains('layout-zones')) return; + var zc = activeLayoutZones() || {}; + var opts = zc.opts || {}; + var inEdit = document.body.classList.contains('layout-edit'); + ['top', 'left', 'right', 'bottom'].forEach(function (z) { + var zn = document.querySelector('#layoutStage .gn-zone[data-zone="' + z + '"]'); + var size = Math.max(5, Math.min(45, parseFloat((opts[z] || {}).size) || ZONE_DEFAULT_SIZE[z])); + var hasVisible = false; + if (zn) { + for (var c = zn.firstElementChild; c; c = c.nextElementSibling) { + if (c.style.display !== 'none') { hasVisible = true; break; } + } + } + stage.style.setProperty('--zone-' + z + '-size', (hasVisible || inEdit) ? size + '%' : '0%'); + }); +} + // ─── §7.16.1 Snap toggle (touch-friendly alternative to holding Shift) ────────────── // Default ON. Persisted across edit sessions in localStorage so a user who // turned snap off for fine positioning doesn't have to do it again next time. @@ -4482,47 +5108,479 @@ function enterLayoutEdit() { renderAll(); window.TIMER_THEME.elements = window.TIMER_THEME.elements || {}; + // Validate any stale pos values (legacy), then convert everything visible to + // v2 boxes measured from the CURRENT rendered layout — the theme looks + // identical the moment edit mode opens; only Save persists the conversion. Object.keys(THEME_SELECTORS).forEach(function(key) { - var node = document.querySelector(THEME_SELECTORS[key]); - if (!node) return; var pe = window.TIMER_THEME.elements[key] = window.TIMER_THEME.elements[key] || {}; - // Validate any existing pos — drop stale/out-of-bounds values from a previous session. if (pe.pos && ( typeof pe.pos.x !== 'number' || typeof pe.pos.y !== 'number' || pe.pos.x < 0 || pe.pos.x > 100 || pe.pos.y < 0 || pe.pos.y > 100 )) { delete pe.pos; } - if (pe.pos) return; - // Hidden elements without a pos: defer seeding until the user selects them from - // the Objects panel. Otherwise they'd silently acquire a position they can't see. + }); + // Edit the orientation the user is physically on; if it has no layout yet + // but the other does, start from a copy of it (in-memory until Save). + EDIT_ORIENTATION = getActiveOrientation(); + ensureOrientationSeeded(EDIT_ORIENTATION); + applyStageLetterbox(); + + if (window.TIMER_THEME.mode !== 'zones') seedBoxes(); + + applyTheme(window.TIMER_THEME); + attachAllDragHandlers(); + openObjectsPanel(); + checkOverlaps(); + setModeButtonsUI(); + setOrientationButtonsUI(); +} + +// Measure every visible element's current rendered rect into a v2 box (stage-%). +// Elements that already have a box keep it; hidden boxless elements defer until +// selected/unhidden (they'd otherwise acquire a position the user can't see). +function seedBoxes() { + var free = activeLayoutFree(true); + var sr = stageRect(); + if (!sr.width || !sr.height) return; + Object.keys(THEME_SELECTORS).forEach(function(key) { + if (validBox(free[key])) return; + var pe = (window.TIMER_THEME.elements || {})[key] || {}; if (pe.visible === false) return; - var rect = node.getBoundingClientRect(); - if (rect.width > 1 && rect.height > 1) { - pe.pos = { - x: ((rect.left + rect.width / 2) / window.innerWidth) * 100, - y: ((rect.top + rect.height / 2) / window.innerHeight) * 100, - }; - } else if (LAYOUT_DEFAULT_POS[key]) { - // Fall back to a sensible default so the element doesn't get stuck in flex - // flow under the other (now-positioned) siblings. - pe.pos = { x: LAYOUT_DEFAULT_POS[key].x, y: LAYOUT_DEFAULT_POS[key].y }; + var node = document.querySelector(THEME_SELECTORS[key]); + var b = null; + if (node && node.style.display !== 'none') { + var r = node.getBoundingClientRect(); + if (r.width > 1 && r.height > 1) { + b = { + x: ((r.left - sr.left) / sr.width) * 100, + y: ((r.top - sr.top) / sr.height) * 100, + w: (r.width / sr.width) * 100, + h: (r.height / sr.height) * 100, + }; + } } + if (!b && BOX_DEFAULTS[key]) b = Object.assign({}, BOX_DEFAULTS[key]); + if (!b) return; + b.w = Math.max(2, Math.min(100, b.w)); + b.h = Math.max(2, Math.min(100, b.h)); + b.x = Math.max(0, Math.min(100 - b.w, b.x)); + b.y = Math.max(0, Math.min(100 - b.h, b.y)); + free[key] = { x: +b.x.toFixed(2), y: +b.y.toFixed(2), w: +b.w.toFixed(2), h: +b.h.toFixed(2) }; }); +} +// ─── Overlap detection: AABB pass over the visible boxes (edit mode only) ─── +function checkOverlaps() { + var badge = document.getElementById('overlapBadge'); + document.querySelectorAll('.box-overlap').forEach(function(n){ n.classList.remove('box-overlap'); }); + // Zones mode can't overlap by construction — nothing to check. + var zonesOn = window.TIMER_THEME && window.TIMER_THEME.mode === 'zones'; + var free = (LAYOUT_EDIT_ON && !zonesOn) ? activeLayoutFree() : null; + if (!free) { if (badge) badge.style.display = 'none'; return; } + var el = (window.TIMER_THEME && window.TIMER_THEME.elements) || {}; + var items = []; + Object.keys(free).forEach(function(k) { + var b = free[k]; + if (!validBox(b)) return; + var pe = el[k]; + if (pe && pe.visible === false) return; + var node = document.querySelector(THEME_SELECTORS[k]); + if (!node) return; + items.push({ b: b, node: node }); + }); + var count = 0; + for (var i = 0; i < items.length; i++) { + for (var j = i + 1; j < items.length; j++) { + var a = items[i].b, c = items[j].b; + if (a.x < c.x + c.w && c.x < a.x + a.w && a.y < c.y + c.h && c.y < a.y + a.h) { + items[i].node.classList.add('box-overlap'); + items[j].node.classList.add('box-overlap'); + count++; + } + } + } + if (badge) { + badge.textContent = '⚠ ' + count; + badge.style.display = count > 0 ? 'inline-block' : 'none'; + } +} + +// ─── Resize frame: 8 handles tracking the single selected box ─── +function syncRzOverlay() { + var ov = document.getElementById('rzOverlay'); + if (!ov) return; + if (window.TIMER_THEME && window.TIMER_THEME.mode === 'zones') { ov.style.display = 'none'; return; } + var free = activeLayoutFree(); + var key = LAYOUT_SELECTED_KEY; + var b = (LAYOUT_EDIT_ON && key && LAYOUT_SELECTION_SET.size === 1 && free) ? free[key] : null; + if (!validBox(b)) { ov.style.display = 'none'; return; } + ov.style.display = 'block'; + ov.style.left = b.x + '%'; + ov.style.top = b.y + '%'; + ov.style.width = b.w + '%'; + ov.style.height = b.h + '%'; +} + +function initResizeHandles() { + document.querySelectorAll('#rzOverlay .gn-rz').forEach(function(h) { + function start(ev) { + ev.preventDefault(); + ev.stopPropagation(); + var key = LAYOUT_SELECTED_KEY; + if (!LAYOUT_EDIT_ON || !key) return; + var free = activeLayoutFree(true); + if (!validBox(free[key])) return; + var dir = h.dataset.dir; + var sr = stageRect(); + var pt = ev.touches ? ev.touches[0] : ev; + var sx = pt.clientX, sy = pt.clientY; + var b0 = free[key]; + b0 = { x: b0.x, y: b0.y, w: b0.w, h: b0.h }; + var node = document.querySelector(THEME_SELECTORS[key]); + function onMove(e2) { + e2.preventDefault(); + var p = e2.touches ? e2.touches[0] : e2; + var dx = ((p.clientX - sx) / sr.width) * 100; + var dy = ((p.clientY - sy) / sr.height) * 100; + var nb = { x: b0.x, y: b0.y, w: b0.w, h: b0.h }; + if (dir.indexOf('e') >= 0) nb.w = b0.w + dx; + if (dir.indexOf('s') >= 0) nb.h = b0.h + dy; + if (dir.indexOf('w') >= 0) { nb.x = b0.x + dx; nb.w = b0.w - dx; } + if (dir.indexOf('n') >= 0) { nb.y = b0.y + dy; nb.h = b0.h - dy; } + if (nb.w < 4) { if (dir.indexOf('w') >= 0) nb.x = b0.x + b0.w - 4; nb.w = 4; } + if (nb.h < 3) { if (dir.indexOf('n') >= 0) nb.y = b0.y + b0.h - 3; nb.h = 3; } + nb.x = Math.max(0, nb.x); + nb.y = Math.max(0, nb.y); + if (nb.x + nb.w > 100) nb.w = 100 - nb.x; + if (nb.y + nb.h > 100) nb.h = 100 - nb.y; + nb.x = Math.round(nb.x * 100) / 100; nb.y = Math.round(nb.y * 100) / 100; + nb.w = Math.round(nb.w * 100) / 100; nb.h = Math.round(nb.h * 100) / 100; + free[key] = nb; + if (node) applyBoxToNode(node, nb); + syncRzOverlay(); + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onUp); + document.removeEventListener('touchcancel', onUp); + checkOverlaps(); + scheduleFit(); + if (typeof refreshBoxRow === 'function') refreshBoxRow(key); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onUp); + document.addEventListener('touchcancel', onUp); + } + h.addEventListener('mousedown', start); + h.addEventListener('touchstart', start, { passive: false }); + }); +} + +// ─── Zone item drag: pick up a ghost, highlight the zone under the pointer, +// drop to reassign with ordering derived from the drop position. A sub- +// threshold press stays a click (select). ─── +function makeZoneDragStart(node, key) { + return function start(ev) { + if (ev.target.closest('.layout-eye')) return; + ev.preventDefault(); + ev.stopPropagation(); + var modifierKey = !!(ev.ctrlKey || ev.metaKey); + var pt = ev.touches ? ev.touches[0] : ev; + var startX = pt.clientX, startY = pt.clientY; + var moved = false, THRESH = 6; + var ghost = null, hlZone = null; + function onMove(e2) { + var p = e2.touches ? e2.touches[0] : e2; + if (!moved && (Math.abs(p.clientX - startX) > THRESH || Math.abs(p.clientY - startY) > THRESH)) { + moved = true; + ghost = node.cloneNode(true); + ghost.style.cssText = 'position:fixed;pointer-events:none;opacity:.55;z-index:1000;margin:0;transform:none;max-width:40vw;max-height:25vh;overflow:hidden;'; + ghost.classList.remove('is-selected', 'box-overlap'); + document.body.appendChild(ghost); + node.style.opacity = '0.3'; + } + if (!moved) return; + e2.preventDefault(); + ghost.style.left = (p.clientX + 10) + 'px'; + ghost.style.top = (p.clientY + 10) + 'px'; + var over = document.elementFromPoint(p.clientX, p.clientY); + over = over && over.closest('.gn-zone'); + if (hlZone && hlZone !== over) hlZone.classList.remove('zone-drop'); + hlZone = over; + if (hlZone) hlZone.classList.add('zone-drop'); + } + function onUp(e3) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onUp); + document.removeEventListener('touchcancel', onUp); + node.style.opacity = ''; + if (ghost) ghost.remove(); + if (hlZone) hlZone.classList.remove('zone-drop'); + if (!moved) { + if (modifierKey) toggleSelectElement(key); else selectElement(key); + return; + } + if (!hlZone) return; + var p = e3.changedTouches ? e3.changedTouches[0] : e3; + dropIntoZone(key, hlZone.dataset.zone, p.clientX, p.clientY); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onUp); + document.addEventListener('touchcancel', onUp); + }; +} + +function dropIntoZone(key, zone, cx, cy) { + if (!ZONE_NAMES[zone]) return; + var z = activeLayoutZones(true); + var zn = document.querySelector('#layoutStage .gn-zone[data-zone="' + zone + '"]'); + if (!zn) return; + var horizontal = (zone === 'top' || zone === 'bottom'); + // Current children of the target zone, in DOM order, mapped back to keys. + var kids = []; + for (var c = zn.firstElementChild; c; c = c.nextElementSibling) { + for (var k in THEME_SELECTORS) { + if (document.querySelector(THEME_SELECTORS[k]) === c) { kids.push({ k: k, node: c }); break; } + } + } + // Insertion point: before the first sibling whose midpoint is past the drop. + var seq = [], inserted = false; + kids.forEach(function (it) { + if (it.k === key) return; + if (!inserted) { + var r = it.node.getBoundingClientRect(); + var mid = horizontal ? (r.left + r.width / 2) : (r.top + r.height / 2); + if ((horizontal ? cx : cy) < mid) { seq.push(key); inserted = true; } + } + seq.push(it.k); + }); + if (!inserted) seq.push(key); + seq.forEach(function (k2, i) { z.assign[k2] = { zone: zone, order: (i + 1) * 10 }; }); + detachAllDragHandlers(); applyTheme(window.TIMER_THEME); + renderAll(); attachAllDragHandlers(); - openObjectsPanel(); + renderObjectsPanel(); + scheduleFit(); +} + +// ─── Zone-boundary dividers: drag the line between zones to resize them ─── +function initZoneDividers() { + document.querySelectorAll('#layoutStage .gn-zdiv').forEach(function (div) { + function start(ev) { + if (!LAYOUT_EDIT_ON) return; + ev.preventDefault(); + ev.stopPropagation(); + var zone = div.dataset.div; // top/bottom/left/right — matches the zone it sizes + var stage = document.getElementById('layoutStage'); + var sr = stage.getBoundingClientRect(); + div.classList.add('is-dragging'); + function onMove(e2) { + e2.preventDefault(); + var p = e2.touches ? e2.touches[0] : e2; + var v; + if (zone === 'top') v = ((p.clientY - sr.top) / sr.height) * 100; + else if (zone === 'bottom') v = ((sr.bottom - p.clientY) / sr.height) * 100; + else if (zone === 'left') v = ((p.clientX - sr.left) / sr.width) * 100; + else v = ((sr.right - p.clientX) / sr.width) * 100; + v = Math.max(5, Math.min(45, v)); + var z = activeLayoutZones(true); + z.opts[zone] = z.opts[zone] || {}; + z.opts[zone].size = Math.round(v * 10) / 10; + stage.style.setProperty('--zone-' + zone + '-size', z.opts[zone].size + '%'); + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onUp); + document.removeEventListener('touchcancel', onUp); + div.classList.remove('is-dragging'); + applyTheme(window.TIMER_THEME); + scheduleFit(); + // Keep the page-inspector numbers in step if it's open on Page. + if (LAYOUT_SELECTED_KEY === null && document.getElementById('layoutInspector').classList.contains('is-open')) { + renderInspector('page'); + } + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onUp); + document.addEventListener('touchcancel', onUp); + } + div.addEventListener('mousedown', start); + div.addEventListener('touchstart', start, { passive: false }); + }); +} + +// ─── Layout mode switcher (Free ↔ Zones). Both sub-trees persist in the theme, +// so switching back and forth loses nothing. ─── +function setLayoutMode(m) { + var t = window.TIMER_THEME; + if (!t || (m !== 'free' && m !== 'zones')) return; + if (t.mode === m) { setModeButtonsUI(); return; } + if (m === 'zones') { + t.mode = 'zones'; + var z = activeLayoutZones(true); + if (!Object.keys(z.assign).length) { + z.assign = JSON.parse(JSON.stringify(DEFAULT_ZONE_ASSIGN)); + } + } else { + t.mode = 'free'; + t.schema = 2; + } + detachAllDragHandlers(); + removeAllEyeIcons(); + applyTheme(t); + renderAll(); + if (m === 'free' && LAYOUT_EDIT_ON) { + // Coming from zones: elements are back in flow slots; give any boxless + // ones a measured box so free mode is immediately draggable. + seedBoxes(); + applyTheme(t); + } + if (LAYOUT_EDIT_ON) { + attachAllDragHandlers(); + checkOverlaps(); + syncRzOverlay(); + renderObjectsPanel(); + if (LAYOUT_SELECTED_KEY) renderInspector(LAYOUT_SELECTED_KEY); + } + setModeButtonsUI(); + scheduleFit(); +} + +function setModeButtonsUI() { + var m = (window.TIMER_THEME && window.TIMER_THEME.mode === 'zones') ? 'zones' : 'free'; + var fb = document.getElementById('modeFreeBtn'); + var zb = document.getElementById('modeZonesBtn'); + if (fb) fb.classList.toggle('mode-on', m === 'free'); + if (zb) zb.classList.toggle('mode-on', m === 'zones'); +} + +// ─── Editor orientation switching + letterboxed simulation ─── +function setOrientationButtonsUI() { + var o = (LAYOUT_EDIT_ON && EDIT_ORIENTATION) ? EDIT_ORIENTATION : getActiveOrientation(); + var lb = document.getElementById('orientLandBtn'); + var pb = document.getElementById('orientPortBtn'); + if (lb) lb.classList.toggle('mode-on', o === 'landscape'); + if (pb) pb.classList.toggle('mode-on', o === 'portrait'); +} + +// Simulate the non-matching orientation by letterboxing the stage to a centered +// 16:9 / 9:16 rect. All geometry is stage-relative (boxes, drags, resize), so +// the simulation is exact; only the viewport-fixed snap GUIDES go dark. +function applyStageLetterbox() { + var stage = document.getElementById('layoutStage'); + if (!stage) return; + var real = getActiveOrientation(); + var want = (LAYOUT_EDIT_ON && EDIT_ORIENTATION) ? EDIT_ORIENTATION : real; + if (!LAYOUT_EDIT_ON || want === real) { + stage.style.position = ''; + stage.style.left = ''; stage.style.top = ''; + stage.style.width = ''; stage.style.height = ''; + document.body.classList.remove('stage-letterbox'); + return; + } + var vw = window.innerWidth, vh = window.innerHeight, w, h; + if (want === 'portrait') { + h = vh * 0.95; w = h * 9 / 16; + if (w > vw * 0.95) { w = vw * 0.95; h = w * 16 / 9; } + } else { + w = vw * 0.95; h = w * 9 / 16; + if (h > vh * 0.95) { h = vh * 0.95; w = h * 16 / 9; } + } + stage.style.position = 'fixed'; + stage.style.left = ((vw - w) / 2) + 'px'; + stage.style.top = ((vh - h) / 2) + 'px'; + stage.style.width = w + 'px'; + stage.style.height = h + 'px'; + document.body.classList.add('stage-letterbox'); +} + +// First edit of an undefined orientation starts from a copy of the defined one +// (in-memory; persists only on Save). Display never seeds — it just falls back. +function ensureOrientationSeeded(o) { + var t = window.TIMER_THEME; + if (!t) return; + t.layouts = t.layouts || {}; + var other = otherOrientation(o); + if (!hasLayoutContent(t.layouts[o]) && hasLayoutContent(t.layouts[other])) { + t.layouts[o] = JSON.parse(JSON.stringify(t.layouts[other])); + } +} + +function setEditOrientation(o) { + if (!LAYOUT_EDIT_ON || (o !== 'landscape' && o !== 'portrait') || EDIT_ORIENTATION === o) return; + deselectElement(); + EDIT_ORIENTATION = o; + ensureOrientationSeeded(o); + detachAllDragHandlers(); + removeAllEyeIcons(); + applyStageLetterbox(); + applyTheme(window.TIMER_THEME); + renderAll(); + if (window.TIMER_THEME.mode !== 'zones') { seedBoxes(); applyTheme(window.TIMER_THEME); } + attachAllDragHandlers(); + checkOverlaps(); + renderObjectsPanel(); + setOrientationButtonsUI(); + scheduleFit(); +} + +function copyFromOtherOrientation() { + var o = EDIT_ORIENTATION || getActiveOrientation(); + var other = otherOrientation(o); + var t = window.TIMER_THEME; + if (!t || !hasLayoutContent(t.layouts && t.layouts[other])) { + pkAlert('The ' + other + ' layout is empty — nothing to copy.'); + return; + } + pkConfirm('Replace the ' + o + ' layout with a copy of the ' + other + ' one? Overlap warnings will show anything that does not survive the aspect change.').then(function (ok) { + if (!ok) return; + t.layouts[o] = JSON.parse(JSON.stringify(t.layouts[other])); + detachAllDragHandlers(); + removeAllEyeIcons(); + applyTheme(t); + renderAll(); + attachAllDragHandlers(); + checkOverlaps(); + renderObjectsPanel(); + scheduleFit(); + }); +} + +// ─── Live preview toggle: position against real data instead of placeholders ─── +window.LIVE_PREVIEW = false; +function toggleLivePreview() { + window.LIVE_PREVIEW = !window.LIVE_PREVIEW; + var btn = document.getElementById('livePreviewBtn'); + if (btn) btn.classList.toggle('snap-on', window.LIVE_PREVIEW); + renderAll(); + checkOverlaps(); } function exitLayoutEdit(keep) { if (!LAYOUT_EDIT_ON) return; LAYOUT_EDIT_ON = false; + EDIT_ORIENTATION = null; + applyStageLetterbox(); // clears any letterboxed simulation document.body.classList.remove('layout-edit'); detachAllDragHandlers(); deselectElement(); removeAllEyeIcons(); closeObjectsPanel(); + checkOverlaps(); // clears all outlines + badge (edit off) if (!keep && LAYOUT_EDIT_SNAPSHOT) { window.TIMER_THEME = LAYOUT_EDIT_SNAPSHOT; } @@ -4572,34 +5630,43 @@ function confirmSaveAsNew() { function resetPositions() { if (!window.TIMER_THEME || !window.TIMER_THEME.elements) return; + // Clear all v2 boxes AND legacy positions, let everything reflow to the + // default flex layout, then re-measure that layout into fresh boxes. + var free = activeLayoutFree(true); + Object.keys(free).forEach(function(k){ delete free[k]; }); Object.keys(window.TIMER_THEME.elements).forEach(function(k){ delete window.TIMER_THEME.elements[k].pos; }); - applyTheme(window.TIMER_THEME); - // After resetting, re-promote elements to dragging using their natural positions. detachAllDragHandlers(); - // Recompute pos values from current rendered positions, then re-attach. - Object.keys(THEME_SELECTORS).forEach(function(key) { - var node = document.querySelector(THEME_SELECTORS[key]); - if (!node) return; - var pe = window.TIMER_THEME.elements[key] = window.TIMER_THEME.elements[key] || {}; - if (pe.visible === false) return; - var rect = node.getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) return; - pe.pos = { - x: ((rect.left + rect.width/2) / window.innerWidth) * 100, - y: ((rect.top + rect.height/2) / window.innerHeight) * 100, - }; - }); + applyTheme(window.TIMER_THEME); + renderAll(); + seedBoxes(); applyTheme(window.TIMER_THEME); attachAllDragHandlers(); + checkOverlaps(); + syncRzOverlay(); + scheduleFit(); } function attachAllDragHandlers() { + var zonesOn = window.TIMER_THEME && window.TIMER_THEME.mode === 'zones'; Object.keys(THEME_SELECTORS).forEach(function(key) { var node = document.querySelector(THEME_SELECTORS[key]); if (!node) return; - if (!node.classList.contains('timer-positioned')) return; + if (zonesOn) { + // Zone items: click = select, drag = pick up a ghost and drop into + // any zone (ordering by drop position). Wheel still resizes. + if (!node.closest('.gn-zone')) return; + attachEyeIcon(node, key); + var handlerZ = makeZoneDragStart(node, key); + var wheelZ = makeWheelScale(key); + node.addEventListener('mousedown', handlerZ); + node.addEventListener('touchstart', handlerZ, { passive: false }); + node.addEventListener('wheel', wheelZ, { passive: false }); + LAYOUT_DRAG_HANDLERS.push({ node: node, handler: handlerZ, wheel: wheelZ }); + return; + } + if (!node.classList.contains('timer-positioned') && !node.classList.contains('timer-boxed')) return; // Eye icon for quick visibility toggle. attachEyeIcon(node, key); // Combined drag-OR-select handler. Movement above threshold = drag (reposition). @@ -4672,7 +5739,17 @@ function toggleElementVisibility(key) { window.TIMER_THEME.elements = window.TIMER_THEME.elements || {}; var pe = window.TIMER_THEME.elements[key] = window.TIMER_THEME.elements[key] || {}; pe.visible = pe.visible === false; // flip + // Unhiding in edit mode: give the element a box (it was skipped by seeding) + // and a drag handler so it's immediately usable. + if (LAYOUT_EDIT_ON && pe.visible !== false) { + var free = activeLayoutFree(true); + if (!validBox(free[key]) && BOX_DEFAULTS[key]) free[key] = Object.assign({}, BOX_DEFAULTS[key]); + detachAllDragHandlers(); + applyTheme(window.TIMER_THEME); + attachAllDragHandlers(); + } applyTheme(window.TIMER_THEME); + if (LAYOUT_EDIT_ON) checkOverlaps(); // Refresh the eye icon glyph (open/closed) for this element. var node = document.querySelector(THEME_SELECTORS[key]); var eye = node && node.querySelector(':scope > .layout-eye'); @@ -4694,18 +5771,22 @@ function makeDragStart(node, key) { // mouseup (toggle vs replace), and whether the element is part of a group drag. var modifierKey = !!(ev.ctrlKey || ev.metaKey); + var free = activeLayoutFree(true); + if (!validBox(free[key])) return; + var sr = stageRect(); var pt = ev.touches ? ev.touches[0] : ev; var startX = pt.clientX, startY = pt.clientY; - var rect = node.getBoundingClientRect(); - var offX = pt.clientX - (rect.left + rect.width / 2); - var offY = pt.clientY - (rect.top + rect.height / 2); - // Dragging element's half-dimensions in % of viewport (stable during drag). - var halfWdr = (rect.width / window.innerWidth) * 50; - var halfHdr = (rect.height / window.innerHeight) * 50; + var box0 = free[key]; + // Dragging box half-dimensions in stage-% (stable during drag). + var halfWdr = box0.w / 2; + var halfHdr = box0.h / 2; + // Pointer offset from the box CENTER, in stage-%. + var offX = ((pt.clientX - sr.left) / sr.width) * 100 - (box0.x + halfWdr); + var offY = ((pt.clientY - sr.top) / sr.height) * 100 - (box0.y + halfHdr); var moved = false; var THRESH = 5; - var SNAP_PCT = 2; // snap-to-center distance (% of viewport) + var SNAP_PCT = 2; // snap-to-center distance (% of stage) var ALIGN_SNAP_PCT = 1.5; // tighter — snap-to-other-element distance var guideV = document.getElementById('centerGuideV'); var guideH = document.getElementById('centerGuideH'); @@ -4724,34 +5805,25 @@ function makeDragStart(node, key) { } var groupStart = {}; groupKeys.forEach(function(gk) { - var ge = window.TIMER_THEME.elements && window.TIMER_THEME.elements[gk]; - if (ge && ge.pos && typeof ge.pos.x === 'number') { - groupStart[gk] = { x: ge.pos.x, y: ge.pos.y }; - } + var gb = free[gk]; + if (validBox(gb)) groupStart[gk] = { x: gb.x, y: gb.y, w: gb.w, h: gb.h }; }); - // Snapshot every other positioned element's center + half-dimensions so - // snap math doesn't repeatedly hit the layout engine during mousemove. - // Exclude the group itself (we shouldn't snap a group to one of its own members). - var others = (window.TIMER_THEME && window.TIMER_THEME.elements) || {}; + // Snapshot every other box's center + half-dimensions from the box map — + // no DOM measurement needed. Exclude the group itself and hidden elements. var groupSet = {}; groupKeys.forEach(function(gk){ groupSet[gk] = 1; }); var othersGeom = []; - for (var ok in others) { - if (groupSet[ok]) continue; - var op = others[ok] && others[ok].pos; - if (!op || typeof op.x !== 'number' || typeof op.y !== 'number') continue; - var sel = THEME_SELECTORS[ok]; - if (!sel) continue; - var otherNode = document.querySelector(sel); - if (!otherNode) continue; - var orect = otherNode.getBoundingClientRect(); - if (orect.width < 1 || orect.height < 1) continue; + Object.keys(free).forEach(function(ok) { + if (groupSet[ok]) return; + var ob = free[ok]; + if (!validBox(ob)) return; + var oe = (window.TIMER_THEME.elements || {})[ok]; + if (oe && oe.visible === false) return; othersGeom.push({ - x: op.x, y: op.y, - halfW: (orect.width / window.innerWidth) * 50, - halfH: (orect.height / window.innerHeight) * 50, + x: ob.x + ob.w / 2, y: ob.y + ob.h / 2, + halfW: ob.w / 2, halfH: ob.h / 2, }); - } + }); // For each other element produce 9 candidate snap targets per axis: // center↔center, edge↔edge (4 combos), and edge↔center (4 combos). @@ -4790,8 +5862,8 @@ function onMove(ev2) { } if (!moved) return; ev2.preventDefault(); - var cx = ((p.clientX - offX) / window.innerWidth) * 100; - var cy = ((p.clientY - offY) / window.innerHeight) * 100; + var cx = ((p.clientX - sr.left) / sr.width) * 100 - offX; + var cy = ((p.clientY - sr.top) / sr.height) * 100 - offY; // Shift bypasses all snapping for fine adjustments. var snapDisabled = !SNAP_ENABLED || !!ev2.shiftKey; @@ -4828,29 +5900,27 @@ function onMove(ev2) { else alignH.classList.remove('is-snapping'); } - cx = Math.max(2, Math.min(98, cx)); - cy = Math.max(2, Math.min(98, cy)); + // Extent-aware clamp: the box's EDGES stay inside the stage. + cx = Math.max(halfWdr, Math.min(100 - halfWdr, cx)); + cy = Math.max(halfHdr, Math.min(100 - halfHdr, cy)); - // Apply the post-snap delta (from primary's starting position) to every - // group member. For a solo drag this loop just runs once for `key`. + // Apply the post-snap delta (from primary's starting center) to every + // group member's top-left. For a solo drag this runs once for `key`. var pStart = groupStart[key]; - var deltaX = pStart ? (cx - pStart.x) : 0; - var deltaY = pStart ? (cy - pStart.y) : 0; - window.TIMER_THEME.elements = window.TIMER_THEME.elements || {}; + var deltaX = pStart ? (cx - (pStart.x + pStart.w / 2)) : 0; + var deltaY = pStart ? (cy - (pStart.y + pStart.h / 2)) : 0; for (var gi = 0; gi < groupKeys.length; gi++) { var gk = groupKeys[gi]; var gs = groupStart[gk]; if (!gs) continue; - var gcx = Math.max(2, Math.min(98, gs.x + deltaX)); - var gcy = Math.max(2, Math.min(98, gs.y + deltaY)); + var gx = Math.max(0, Math.min(100 - gs.w, gs.x + deltaX)); + var gy = Math.max(0, Math.min(100 - gs.h, gs.y + deltaY)); + var nb = { x: Math.round(gx * 100) / 100, y: Math.round(gy * 100) / 100, w: gs.w, h: gs.h }; + free[gk] = nb; var gn = document.querySelector(THEME_SELECTORS[gk]); - if (gn) { - gn.style.setProperty('--pos-x', gcx + '%'); - gn.style.setProperty('--pos-y', gcy + '%'); - } - window.TIMER_THEME.elements[gk] = window.TIMER_THEME.elements[gk] || {}; - window.TIMER_THEME.elements[gk].pos = { x: gcx, y: gcy }; + if (gn) applyBoxToNode(gn, nb); } + syncRzOverlay(); } function onUp() { document.removeEventListener('mousemove', onMove); @@ -4866,6 +5936,9 @@ function onUp() { // Treat as click. Ctrl/Cmd toggles multi-selection; plain click replaces. if (modifierKey) toggleSelectElement(key); else selectElement(key); + } else { + checkOverlaps(); + scheduleFit(); } } document.addEventListener('mousemove', onMove); @@ -4881,12 +5954,13 @@ function onUp() { var LAYOUT_SELECTION_SET = new Set(); // all selected keys (always contains primary) function updateSelectionVisuals() { - document.querySelectorAll('.timer-positioned.is-selected').forEach(function(n){ n.classList.remove('is-selected'); }); + document.querySelectorAll('.timer-positioned.is-selected, .timer-boxed.is-selected').forEach(function(n){ n.classList.remove('is-selected'); }); LAYOUT_SELECTION_SET.forEach(function(k) { var sel = THEME_SELECTORS[k]; var n = sel && document.querySelector(sel); if (n) n.classList.add('is-selected'); }); + syncRzOverlay(); } function selectElement(key) { @@ -4931,7 +6005,8 @@ function toggleSelectElement(key) { function deselectElement() { LAYOUT_SELECTED_KEY = null; LAYOUT_SELECTION_SET.clear(); - document.querySelectorAll('.timer-positioned.is-selected').forEach(function(n){ n.classList.remove('is-selected'); }); + document.querySelectorAll('.timer-positioned.is-selected, .timer-boxed.is-selected').forEach(function(n){ n.classList.remove('is-selected'); }); + syncRzOverlay(); var panel = document.getElementById('layoutInspector'); if (panel) panel.classList.remove('is-open'); if (window.TIMER_THEME) applyTheme(window.TIMER_THEME); @@ -4963,6 +6038,10 @@ function objectsSortedMetas() { function renderObjectsPanel() { var body = document.getElementById('objectsBody'); if (!body) return; + if (window.TIMER_THEME && window.TIMER_THEME.mode === 'zones') { + renderObjectsPanelZones(body); + return; + } var metas = objectsSortedMetas(); var html = ''; metas.forEach(function(meta, i) { @@ -4991,6 +6070,97 @@ function renderObjectsPanel() { attachObjectsDrag(); } +// ─── Zones-mode Objects panel: rows grouped by zone, with a zone dropdown and +// ▲/▼ IN-ZONE order controls per row, plus an "Unplaced" group for elements +// with no zone (they don't render until placed). ─── +var ZONE_LABELS = { top: 'Top bar', left: 'Left rail', center: 'Center stage', right: 'Right rail', bottom: 'Bottom bar', '': 'Unplaced' }; + +function renderObjectsPanelZones(body) { + var z = activeLayoutZones(true); + var assign = z.assign; + var groups = { top: [], left: [], center: [], right: [], bottom: [], '': [] }; + THEME_ELEMENTS.forEach(function (meta) { + var a = assign[meta.key]; + var zone = (a && ZONE_NAMES[a.zone]) ? a.zone : ''; + groups[zone].push({ meta: meta, order: a ? (parseInt(a.order, 10) || 0) : 0 }); + }); + var zoneOpts = ZONE_LIST.map(function (zn) { return ''; }).join(''); + var html = ''; + ZONE_LIST.concat(['']).forEach(function (zone) { + var rows = groups[zone]; + if (zone === '' && !rows.length) return; + rows.sort(function (a, b) { return a.order - b.order; }); + html += '
' + ZONE_LABELS[zone] + '
'; + if (!rows.length) { + html += '
empty
'; + return; + } + rows.forEach(function (it, i) { + var meta = it.meta; + var pe = (window.TIMER_THEME.elements || {})[meta.key] || {}; + var hidden = (pe.visible === false); + var selected = LAYOUT_SELECTION_SET && LAYOUT_SELECTION_SET.has && LAYOUT_SELECTION_SET.has(meta.key); + var rowCls = 'layout-object-row' + (selected ? ' is-selected' : '') + (hidden ? ' is-hidden' : ''); + var eyeCls = 'obj-eye' + (hidden ? ' is-hidden' : ''); + var eyeGlyph = hidden ? '👀' : '👁'; + var safeKey = meta.key.replace(/'/g, "\\'"); + var sel = zoneOpts.replace('value="' + zone + '"', 'value="' + zone + '" selected'); + html += '
' + + '' + + '' + meta.label + '' + + '' + + '' + + '' + + '' + + '' + + '
'; + }); + }); + body.innerHTML = html; +} + +function setElementZone(key, zone) { + if (!ZONE_NAMES[zone]) return; + var z = activeLayoutZones(true); + // Append at the end of the target zone. + var maxOrder = 0; + Object.keys(z.assign).forEach(function (k) { + var a = z.assign[k]; + if (a && a.zone === zone) maxOrder = Math.max(maxOrder, parseInt(a.order, 10) || 0); + }); + z.assign[key] = { zone: zone, order: maxOrder + 10 }; + detachAllDragHandlers(); + applyTheme(window.TIMER_THEME); + renderAll(); + if (LAYOUT_EDIT_ON) attachAllDragHandlers(); + renderObjectsPanel(); + scheduleFit(); +} + +function moveZoneOrder(key, dir) { + var z = activeLayoutZones(true); + var a = z.assign[key]; + if (!a) return; + // Ordered siblings in the same zone. + var sibs = Object.keys(z.assign) + .filter(function (k) { return z.assign[k] && z.assign[k].zone === a.zone; }) + .sort(function (x, y) { return (parseInt(z.assign[x].order, 10) || 0) - (parseInt(z.assign[y].order, 10) || 0); }); + var idx = sibs.indexOf(key); + var swap = idx + dir; + if (idx < 0 || swap < 0 || swap >= sibs.length) return; + var other = sibs[swap]; + var tmp = z.assign[key].order; + z.assign[key].order = z.assign[other].order; + z.assign[other].order = tmp; + applyTheme(window.TIMER_THEME); + renderObjectsPanel(); + scheduleFit(); +} + // Assign explicit z_index to every element from a top→bottom (front→back) key // list: top row gets the highest value. Clamped into 1..N which stays well below // the control tray (z25) / edit pill (z40) / modals, so "bring to front" never @@ -5212,6 +6382,18 @@ function renderInspector(key) { + '' + '
'); + // Box size (v2): numeric W/H steppers in stage-%, live while resizing. + var _fb = activeLayoutFree(); + var _bx = _fb && _fb[key]; + if (_bx && typeof _bx.w === 'number') { + rows.push('' + + '
' + + '' + + 'W ' + + 'H ' + + '%
'); + } + // Reset position rows.push('' + '
' @@ -5390,6 +6572,28 @@ function bgRow(val, label, hidden) { + '
' + ''); + // Per-zone options (zones mode only): size of the four edge zones, content + // alignment, and gap between items. + if (window.TIMER_THEME.mode === 'zones') { + var zc = activeLayoutZones(true); + rows.push('
Zones
'); + ZONE_LIST.forEach(function (zn) { + var o = zc.opts[zn] || {}; + var size = Math.max(5, Math.min(45, parseFloat(o.size) || ZONE_DEFAULT_SIZE[zn] || 0)); + var alignSel = ['start', 'center', 'end'].map(function (a) { + return ''; + }).join(''); + rows.push('
' + + '' + + (zn !== 'center' + ? '%' + : '') + + '' + + '' + + '
'); + }); + } + rows.push('
Toolbar
'); rows.push('
' + '
'); @@ -5589,9 +6793,12 @@ function onInspectorScale(key, delta) { function resetElementPosition(key) { var pe = (window.TIMER_THEME.elements || {})[key]; if (pe) delete pe.pos; - // Re-capture from a sensible default so the element stays draggable. - if (LAYOUT_DEFAULT_POS[key]) pe.pos = { x: LAYOUT_DEFAULT_POS[key].x, y: LAYOUT_DEFAULT_POS[key].y }; + var free = activeLayoutFree(true); + delete free[key]; + // Re-seed from the heuristic default so the element stays draggable. + if (BOX_DEFAULTS[key]) free[key] = Object.assign({}, BOX_DEFAULTS[key]); applyTheme(window.TIMER_THEME); + if (LAYOUT_EDIT_ON) { checkOverlaps(); syncRzOverlay(); scheduleFit(); } } function refreshHiddenInInspector() { @@ -5600,6 +6807,47 @@ function refreshHiddenInInspector() { if (LAYOUT_SELECTED_KEY) renderInspector(LAYOUT_SELECTED_KEY); } +// Page-inspector zone option commit (size / align / gap for one zone). +function onZoneOpt(zone, field, val) { + if (!ZONE_NAMES[zone]) return; + var z = activeLayoutZones(true); + var o = z.opts[zone] = z.opts[zone] || {}; + if (field === 'size') o.size = Math.max(5, Math.min(45, parseFloat(val) || ZONE_DEFAULT_SIZE[zone])); + if (field === 'gap') o.gap = Math.max(0, Math.min(8, parseFloat(val) || 0)); + if (field === 'align') o.align = { start: 1, center: 1, end: 1 }[val] ? val : 'center'; + applyTheme(window.TIMER_THEME); + scheduleFit(); +} + +// Inspector W/H stepper commit — resizes the box keeping its top-left anchored. +function onInspectorBox(key, dim, val) { + var free = activeLayoutFree(true); + var b = free[key]; + if (!validBox(b)) return; + var v = parseFloat(val); + if (isNaN(v)) { refreshBoxRow(key); return; } + if (dim === 'w') b.w = Math.max(4, Math.min(100 - b.x, v)); + if (dim === 'h') b.h = Math.max(3, Math.min(100 - b.y, v)); + var node = document.querySelector(THEME_SELECTORS[key]); + if (node) applyBoxToNode(node, b); + syncRzOverlay(); + checkOverlaps(); + scheduleFit(); + refreshBoxRow(key); +} + +// Sync the inspector's Box row inputs after a handle-drag resize. +function refreshBoxRow(key) { + if (LAYOUT_SELECTED_KEY !== key) return; + var row = document.getElementById('ins_box_' + key); + var free = activeLayoutFree(); + var b = free && free[key]; + if (!row || !validBox(b)) return; + var inputs = row.querySelectorAll('input'); + if (inputs[0]) inputs[0].value = Math.round(b.w); + if (inputs[1]) inputs[1].value = Math.round(b.h); +} + // ─── §7.18 Generic drag-by-header helper for the pill & inspector panel ── function makePanelDraggable(panel, handle) { if (!panel || !handle) return; @@ -5683,8 +6931,29 @@ function onUp() { }); // ─── §7.19 Init ───────────────────────────────────────────────── +// Adopt body-level themable elements into the stage so boxed %-geometry is +// stage-relative — vital when the stage letterboxes for orientation preview. +// Runs before the first applyTheme so ORIGINAL_SLOTS capture the new homes. +(function adoptIntoStage() { + var stage = document.getElementById('layoutStage'); + if (!stage) return; + ['qrWrap', 'themeImage', 'streamingWrap'].forEach(function (id) { + var n = document.getElementById(id); + if (n && !n.closest('#layoutStage')) stage.appendChild(n); + }); +})(); if (window.TIMER_THEME) applyTheme(window.TIMER_THEME); renderAll(); +// First theme application done — lift the first-paint gate (the head-script +// failsafe would lift it at 2.5s anyway if we never got here). +document.documentElement.classList.remove('theme-pending'); +if (typeof initResizeHandles === 'function' && document.getElementById('rzOverlay')) initResizeHandles(); +if (typeof initZoneDividers === 'function') initZoneDividers(); +// Viewport-shape changes re-apply layout + fit (debounced in reapplyLayout). +window.addEventListener('resize', reapplyLayout); +window.addEventListener('orientationchange', reapplyLayout); +document.addEventListener('fullscreenchange', reapplyLayout); +try { window.matchMedia('(orientation: portrait)').addEventListener('change', reapplyLayout); } catch (e) { /* older Safari */ } startLocalTick(); // smooth second-by-second display between polls setInterval(pollState, POLL_INTERVAL); // everyone polls server — server is master