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);
?>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Avg Stack
@@ -2013,18 +2238,105 @@ function fmtWallTime(secsFromNow) {
}
// ─── §7.3 Render ───────────────────────────────────────────────
+// Data-availability map: renderAll records whether each themable widget has
+// anything to show; syncVisibility() (§7.15.0) is the ONLY writer of
+// style.display and combines this with theme visibility. Keys absent from the
+// map are always-available (clock, blinds, event name, ...).
+window.DATA_AVAIL = window.DATA_AVAIL || {};
+
+// Change-guarded writers: skip the DOM write when the content is unchanged.
+// Keeps the 2s poll from dirtying layout (and, later, from re-running fit-text).
+function setText(node, s) {
+ if (!node) return;
+ if (node._gnTxt !== s) { node._gnTxt = s; node._gnHtml = undefined; node.textContent = s; window._contentDirty = true; }
+}
+function setHtml(node, s) {
+ if (!node) return;
+ if (node._gnHtml !== s) { node._gnHtml = s; node._gnTxt = undefined; node.innerHTML = s; window._contentDirty = true; }
+}
+
+// ─── §7.3.1 Fit-text ────────────────────────────────────────────
+// Auto-shrink: an element's themed font-size is its MAXIMUM. When nowrap
+// content is wider or taller than the space the element is allowed (soft
+// extent caps on positioned elements, container width in flow, real boxes in
+// later phases), the font scales down just enough to fit — long event names
+// and 1.2K/2.4K blinds stop sliding under their neighbors. Batched into
+// clear-all → read-all → write-all so each cycle costs one reflow, and only
+// scheduled when content or layout actually changed.
+var FIT_SKIP = { qr: 1, image: 1, streaming: 1 };
+var FIT_MIN_PX = 8;
+
+function fitAllText() {
+ var nodes = [];
+ for (var k in THEME_SELECTORS) {
+ if (FIT_SKIP[k]) continue;
+ var node = document.querySelector(THEME_SELECTORS[k]);
+ if (!node || node.style.display === 'none') continue;
+ nodes.push(node);
+ }
+ // Pass 1 (write): clear any previous fit so we measure at the themed max.
+ nodes.forEach(function (n) { n.style.fontSize = ''; });
+ // Pass 2 (read): CSS base sizes.
+ var bases = nodes.map(function (n) { return parseFloat(getComputedStyle(n).fontSize) || 0; });
+ // Pass 2b (write): boxed widgets whose scale became a font multiplier
+ // (data-box-scale, set by applyTheme) measure at base × scale.
+ var anyScaled = false;
+ nodes.forEach(function (n, i) {
+ var bs = parseFloat(n.dataset.boxScale) || 0;
+ if (bs && bs !== 1 && bases[i]) { n.style.fontSize = (bases[i] * bs) + 'px'; bases[i] = bases[i] * bs; anyScaled = true; }
+ });
+ // Pass 3 (read): overflow measurements (second layout only if 2b wrote).
+ var meas = nodes.map(function (n, i) {
+ return {
+ n: n, base: bases[i],
+ cw: n.clientWidth, sw: n.scrollWidth,
+ ch: n.clientHeight, sh: n.scrollHeight,
+ };
+ });
+ // Pass 4 (write): shrink only where content genuinely overflows.
+ meas.forEach(function (m) {
+ if (!m.base) return;
+ var r = 1;
+ if (m.sw > m.cw + 1 && m.sw > 0) r = Math.min(r, m.cw / m.sw);
+ if (m.sh > m.ch + 1 && m.sh > 0) r = Math.min(r, m.ch / m.sh);
+ if (r < 1) m.n.style.fontSize = Math.max(FIT_MIN_PX, Math.floor(m.base * r)) + 'px';
+ });
+}
+
+var _fitQueued = false;
+function scheduleFit() {
+ if (_fitQueued) return;
+ _fitQueued = true;
+ requestAnimationFrame(function () { _fitQueued = false; fitAllText(); });
+}
+
+// Re-apply layout + fit when the viewport itself changes (window resize,
+// device rotation, entering/leaving fullscreen). Debounced — resize fires in
+// bursts. This is the hook the timer never had: positions are % of viewport,
+// so nothing used to re-evaluate when the viewport changed shape.
+var _relayoutTimer = null;
+function reapplyLayout() {
+ clearTimeout(_relayoutTimer);
+ _relayoutTimer = setTimeout(function () {
+ if (typeof applyStageLetterbox === 'function') applyStageLetterbox();
+ if (typeof setOrientationButtonsUI === 'function') setOrientationButtonsUI();
+ if (window.TIMER_THEME) applyTheme(window.TIMER_THEME);
+ scheduleFit();
+ }, 150);
+}
+
function renderAll() {
var lv = getLevelData(TIMER.current_level);
var el = document.getElementById.bind(document);
if (lv) {
if (parseInt(lv.is_break)) {
- el('levelLabel').textContent = 'BREAK';
+ setText(el('levelLabel'), 'BREAK');
// While running, show the wall-clock end of the break ("Until 9:15 PM")
// so nobody has to do countdown math at the snack table.
- el('blinds').textContent = TIMER.is_running
+ setText(el('blinds'), TIMER.is_running
? 'Until ' + fmtWallTime(Math.max(0, parseInt(TIMER.time_remaining_seconds) || 0))
- : 'Break Time';
+ : 'Break Time');
el('ante').textContent = '';
} else {
// Count play levels only
@@ -2033,13 +2345,16 @@ function renderAll() {
if (!parseInt(LEVELS[i].is_break)) playNum++;
if (parseInt(LEVELS[i].level_number) === TIMER.current_level) break;
}
- el('levelLabel').textContent = 'Level ' + playNum;
+ setText(el('levelLabel'), 'Level ' + playNum);
var blindsHtml = fmtChips(parseFloat(lv.small_blind)) + ' / ' + fmtChips(parseFloat(lv.big_blind));
if (parseFloat(lv.ante) > 0) {
- blindsHtml += ' /
' + fmtChips(parseFloat(lv.ante))
- + 'ANTE ';
+ // In-flow stacked ante (number over its ANTE label): the label
+ // occupies real height so measurement/fit-text sees it — the old
+ // absolutely-hung version was invisible to every bounding box.
+ blindsHtml += ' /
' + fmtChips(parseFloat(lv.ante))
+ + 'ANTE ';
}
- el('blinds').innerHTML = blindsHtml;
+ setHtml(el('blinds'), blindsHtml);
el('ante').textContent = '';
}
}
@@ -2048,132 +2363,119 @@ function renderAll() {
var nextLv = getLevelData(TIMER.current_level + 1);
if (nextLv) {
if (parseInt(nextLv.is_break)) {
- el('nextLevel').innerHTML = 'Next: Break';
+ setHtml(el('nextLevel'), 'Next: Break');
} else {
var nextHtml = 'Next: ' + fmtChips(parseFloat(nextLv.small_blind)) + ' / ' + fmtChips(parseFloat(nextLv.big_blind));
if (parseFloat(nextLv.ante) > 0) {
- nextHtml += ' /
' + fmtChips(parseFloat(nextLv.ante))
- + 'ANTE ';
+ nextHtml += ' /
' + fmtChips(parseFloat(nextLv.ante))
+ + 'ANTE ';
}
- el('nextLevel').innerHTML = nextHtml;
+ setHtml(el('nextLevel'), nextHtml);
}
} else {
- el('nextLevel').innerHTML = 'Final Level';
+ setHtml(el('nextLevel'), 'Final Level');
}
renderClock();
renderPlayBtn();
- // Stats
- // While in layout-edit mode, force-show all themable widgets even if their normal
- // display rules say "no data, hide me" — the user is positioning, not playing.
+ // Stats — renderAll only writes CONTENT and records data availability in
+ // DATA_AVAIL; syncVisibility() owns style.display. In layout-edit mode it
+ // force-shows widgets with the placeholder text set below.
var _inEdit = document.body.classList.contains('layout-edit');
if (POOL) {
- var pc = el('playerCount'), pt = el('poolTotal');
- if (pc) pc.textContent = (POOL.still_playing || 0) + '/' + (POOL.bought_in || 0);
- if (pt) pt.textContent = fmtMoney(POOL.pool_total || 0);
+ setText(el('playerCount'), (POOL.still_playing || 0) + '/' + (POOL.bought_in || 0));
+ setText(el('poolTotal'), fmtMoney(POOL.pool_total || 0));
}
// Pool + Players are always visible — theme.visible controls them if the user wants to hide.
// Average stack (tournament only)
- var avgWrap = el('avgStackWrap');
- var avgVal = el('avgStackValue');
- if (avgWrap && avgVal) {
- var stillPlaying = POOL ? (POOL.still_playing || 0) : 0;
- var chipsInPlay = POOL ? (POOL.chips_in_play || 0) : 0;
- if (GAME_TYPE === 'tournament' && stillPlaying > 0 && chipsInPlay > 0) {
- var avg = Math.round(chipsInPlay / stillPlaying);
- avgVal.textContent = avg.toLocaleString();
- avgWrap.style.display = '';
- } else {
- avgWrap.style.display = _inEdit ? '' : 'none';
- if (_inEdit && !avgVal.textContent) avgVal.textContent = '-';
- }
+ var avgVal = el('avgStackValue');
+ var stillPlaying = POOL ? (POOL.still_playing || 0) : 0;
+ var chipsInPlay = POOL ? (POOL.chips_in_play || 0) : 0;
+ if (GAME_TYPE === 'tournament' && stillPlaying > 0 && chipsInPlay > 0) {
+ setText(avgVal, Math.round(chipsInPlay / stillPlaying).toLocaleString());
+ DATA_AVAIL.avg_stack = true;
+ } else {
+ DATA_AVAIL.avg_stack = false;
+ if (_inEdit && avgVal && !avgVal.textContent) setText(avgVal, '25,000');
}
// Reentries (tournament only) — total rebuys across the field
- var rbWrap = el('rebuysWrap'), rbVal = el('rebuysCount');
- if (rbWrap && rbVal) {
- if (GAME_TYPE === 'tournament' && POOL) {
- rbVal.textContent = (POOL.total_rebuys || 0);
- rbWrap.style.display = '';
- } else {
- rbWrap.style.display = _inEdit ? '' : 'none';
- }
+ if (GAME_TYPE === 'tournament' && POOL) {
+ setText(el('rebuysCount'), String(POOL.total_rebuys || 0));
+ DATA_AVAIL.rebuys = true;
+ } else {
+ DATA_AVAIL.rebuys = false;
}
// Chips in play (tournament only) — server-computed, single source of truth
- var cpWrap = el('chipsInPlayWrap'), cpVal = el('chipsInPlayVal');
- if (cpWrap && cpVal) {
- if (GAME_TYPE === 'tournament' && POOL && (POOL.chips_in_play || 0) > 0) {
- cpVal.textContent = (POOL.chips_in_play || 0).toLocaleString();
- cpWrap.style.display = '';
- } else {
- cpWrap.style.display = _inEdit ? '' : 'none';
- if (_inEdit && !cpVal.textContent) cpVal.textContent = '0';
- }
+ var cpVal = el('chipsInPlayVal');
+ if (GAME_TYPE === 'tournament' && POOL && (POOL.chips_in_play || 0) > 0) {
+ setText(cpVal, (POOL.chips_in_play || 0).toLocaleString());
+ DATA_AVAIL.chips_in_play = true;
+ } else {
+ DATA_AVAIL.chips_in_play = false;
+ if (_inEdit && cpVal && !cpVal.textContent) setText(cpVal, '150,000');
}
// Next break countdown (tournament only) — derived client-side from LEVELS
- var nbWrap = el('nextBreakWrap'), nbVal = el('nextBreakClock');
- if (nbWrap && nbVal) {
- var nbSecs = (GAME_TYPE === 'tournament') ? computeNextBreakSeconds() : null;
- if (nbSecs !== null) {
- nbVal.textContent = fmtBreakClock(Math.max(0, nbSecs));
- nbWrap.style.display = '';
- } else {
- nbWrap.style.display = _inEdit ? '' : 'none';
- if (_inEdit) nbVal.textContent = '--:--';
- }
+ var nbVal = el('nextBreakClock');
+ var nbSecs = (GAME_TYPE === 'tournament') ? computeNextBreakSeconds() : null;
+ if (nbSecs !== null) {
+ setText(nbVal, fmtBreakClock(Math.max(0, nbSecs)));
+ DATA_AVAIL.next_break = true;
+ } else {
+ DATA_AVAIL.next_break = false;
+ if (_inEdit) setText(nbVal, '--:--');
}
// Estimated finish ("Ends: ≈ 11:40 PM") — remaining time through the whole
// structure. Only meaningful while the clock runs; paused estimates drift.
- var eaWrap = el('endsAtWrap'), eaVal = el('endsAtVal');
- if (eaWrap && eaVal) {
- var eaSecs = computeTotalRemainingSeconds();
- if (eaSecs !== null && eaSecs > 0 && TIMER.is_running) {
- eaVal.textContent = '≈ ' + fmtWallTime(eaSecs);
- eaWrap.style.display = '';
- } else {
- eaWrap.style.display = _inEdit ? '' : 'none';
- if (_inEdit) eaVal.textContent = '≈ --:--';
- }
+ var eaVal = el('endsAtVal');
+ var eaSecs = computeTotalRemainingSeconds();
+ if (eaSecs !== null && eaSecs > 0 && TIMER.is_running) {
+ setText(eaVal, '≈ ' + fmtWallTime(eaSecs));
+ DATA_AVAIL.ends_at = true;
+ } else {
+ DATA_AVAIL.ends_at = false;
+ if (_inEdit) setText(eaVal, '≈ --:--');
}
// Payouts (tournament only)
- var payWrap = el('payoutsWrap');
var payBody = el('payoutsBody');
- if (payWrap && payBody) {
- 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(/' + (ordinals[i] || (i+1)+'th') + ':
' + fmtMoney(amt) + ' ' + (pct > 0 ? ' (' + pct + '%)' : '') + extra + '
';
- }
- 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(/' + (ordinals[i] || (i+1)+'th') + ':
' + 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 '
' + ZONE_LABELS[zn] + ' '; }).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 += '
'
+ + '' + eyeGlyph + ' '
+ + '' + meta.label + ' '
+ + ''
+ + (zone === '' ? '— ' : '') + sel + ' '
+ + ''
+ + '▲ '
+ + '▼ '
+ + ' '
+ + '
';
+ });
+ });
+ 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(''
+ + '