Rewrite fullscreen as history entries: each open=push, each back=pop

Single winStack[] array + single popstate handler replaces:
- Per-modal popHandlers (removed)
- _modalPopHandled flag (removed)
- overlay._pushFullscreen (removed)
- _suspended/resume mechanism (removed)

How it works:
- winPush(type, closeFn): push entry + history.pushState
- winPopTop(): pop top entry, call close(), return true
- closeModal(): pop all entries down to stackBase, history.go(-count)
- _skipPops counter: closeModal's history.go fires popstate — skip N events

Every open (modal, fullscreen map, fullscreen chart) = 1 winPush.
Every back = 1 winPopTop via popstate.
closeModal for buttons = pop N entries + history.go(-N) + skip N pops.

Sub-modals work because stackBase tracks where each modal started.
Ingredient modal pops only its own entry, recipe editor stays.
This commit is contained in:
4aiman 2026-05-28 22:24:57 +03:00
commit 4960f2a863

View file

@ -26,8 +26,23 @@ function persistNavStack() {
try { localStorage.setItem("baldav_nav_stack", JSON.stringify(navStack)); } catch {}
}
// Flag: modal popHandler already handled this popstate, global handler should skip
let _modalPopHandled = false;
// ---------------------------------------------------------------------------
// Window stack — single global stack for ALL overlays (modals, fullscreen).
// Every open = push. Every back = pop top. One popstate handler.
// ---------------------------------------------------------------------------
const winStack = []; // { type, close() }
let _skipPops = 0; // counter: how many popstate events to skip (from closeModal's history.go)
function winPush(type, closeFn) {
winStack.push({ type, close: closeFn });
history.pushState({ win: type }, "");
}
function winPopTop() {
if (winStack.length === 0) return false;
winStack.pop().close();
return true;
}
// ---------------------------------------------------------------------------
// Boot
@ -766,24 +781,17 @@ async function renderStatsCharts(container, days, foodTotals, bmr) {
return;
}
// Bind chart fullscreen toggle for stats charts
// Bind chart fullscreen toggle for stats charts (Today screen, no modal)
container.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
history.pushState({ chartFull: true }, "");
const closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "✕";
function collapseChart() {
wrap.classList.remove("chart-full");
closeBtn.remove();
window.removeEventListener("popstate", chartPopHandler);
}
closeBtn.onclick = (e) => { e.stopPropagation(); collapseChart(); history.back(); };
const chartPopHandler = () => { collapseChart(); };
window.addEventListener("popstate", chartPopHandler, { once: true });
closeBtn.onclick = (e) => { e.stopPropagation(); history.back(); };
wrap.appendChild(closeBtn);
winPush("fsChart", () => { wrap.classList.remove("chart-full"); closeBtn.remove(); });
};
});
}
@ -808,62 +816,16 @@ function showModal(html, onSubmit) {
</div>`;
document.body.appendChild(overlay);
// Simple history stack: each open = pushState, each back = popState pops it.
// Modal = 1 entry. Fullscreen map/chart inside modal = 1 more entry each.
// Back always pops the top entry. No re-pushing, no flags.
let _historyDepth = 1; // how many entries this modal owns
history.pushState({ modal: true }, "");
const popHandler = () => {
// Skip if another modal's closeModal triggered this popstate
if (_modalPopHandled) return;
// If this overlay was already removed — skip
if (!overlay.parentNode) return;
_historyDepth--;
// Check what's on top and collapse it
const fsMap = overlay.querySelector(".sd-map-full");
if (fsMap) {
fsMap.classList.remove("sd-map-full");
const expandBtn = fsMap.querySelector(".sd-map-expand-btn");
if (expandBtn) expandBtn.innerHTML = "&#x26F6;";
try {
const mapObj = fsMap._leafletMap;
if (mapObj) {
mapObj.dragging.disable();
mapObj.scrollWheelZoom.disable();
setTimeout(() => { mapObj.invalidateSize(); if (fsMap._bounds) mapObj.fitBounds(fsMap._bounds); }, 100);
}
} catch {}
return; // stay in modal, depth already decremented
}
const fsChart = overlay.querySelector(".chart-full");
if (fsChart) {
fsChart.classList.remove("chart-full");
const btn = fsChart.querySelector(".chart-close-btn");
if (btn) btn.remove();
return; // stay in modal
}
// Nothing fullscreen — close modal
_modalPopHandled = true;
window.removeEventListener("popstate", popHandler);
overlay.remove();
};
window.addEventListener("popstate", popHandler);
// Called when fullscreen opens inside this modal — push 1 entry
overlay._pushFullscreen = function() {
_historyDepth++;
history.pushState({ modalFs: true }, "");
};
const stackBase = winStack.length; // remember stack depth before our push
winPush("modal", () => overlay.remove());
function closeModal() {
window.removeEventListener("popstate", popHandler);
overlay.remove();
_modalPopHandled = true;
history.back();
// Pop all entries this modal added (fullscreen children + modal itself)
const count = winStack.length - stackBase;
for (let i = 0; i < count; i++) winStack.pop().close();
// Jump history back by that many steps; skip the resulting popstate events
_skipPops = count;
if (count > 0) history.go(-count);
}
overlay.querySelector("#modal-back").onclick = () => closeModal();
@ -872,7 +834,6 @@ function showModal(html, onSubmit) {
const ok = await onSubmit(overlay);
if (ok !== false) { closeModal(); if (ok !== "norender") renderScreen(state.screen); }
};
setTimeout(() => overlay.querySelector("input")?.focus(), 50);
}
function showWeightModal() {
@ -1428,18 +1389,25 @@ async function showRecipeDetailModal(recipeId) {
logBtn.onclick = async () => {
await DB.addFoodFromRecipe(state.date, nowTime(), recipeId);
history.back(); // popHandler closes modal
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen(state.screen);
};
editBtn.onclick = () => {
history.back(); // popHandler closes modal
setTimeout(() => showRecipeEditorModal(recipeId), 50);
// Close this modal via winStack, then open editor
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showRecipeEditorModal(recipeId);
};
delBtn.onclick = async () => {
await DB.deleteRecipe(recipeId);
history.back(); // popHandler closes modal
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("recipes");
};
}
@ -2554,7 +2522,7 @@ async function showSessionDetail(sessionId) {
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
mapEl._leafletMap = map; // save for popHandler map collapse
mapEl._leafletMap = map; // save ref for winStack collapse
// Ctrl+wheel zoom on desktop
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
@ -2616,7 +2584,7 @@ async function showSessionDetail(sessionId) {
// Fullscreen map toggle
function toggleMapFs() {
if (mapEl.classList.contains("sd-map-full")) {
// Collapse via history.back() — popHandler will handle the CSS
// Collapse via history.back() — winStack close handler will reset CSS
history.back();
} else {
// Expand — push to history stack
@ -2625,9 +2593,16 @@ async function showSessionDetail(sessionId) {
map.dragging.enable();
map.scrollWheelZoom.enable();
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
// Push fullscreen entry — back will pop it
const modal = mapEl.closest(".modal-overlay");
if (modal && modal._pushFullscreen) modal._pushFullscreen();
// Push fullscreen map onto winStack
const parentOverlay = mapEl.closest(".modal-overlay");
winPush("fsMap", () => {
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
map.dragging.disable();
map.scrollWheelZoom.disable();
setTimeout(() => { map.invalidateSize(); if (mapEl._bounds) map.fitBounds(mapEl._bounds); }, 100);
});
winStack[winStack.length - 1].overlay = parentOverlay;
}
}
@ -2794,22 +2769,22 @@ async function showSessionDetail(sessionId) {
const exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
// Chart fullscreen toggle — tap to expand, tap X or back to collapse
// Chart fullscreen in modal — pushState so back collapses it
// Chart fullscreen in modal — push to winStack, back collapses
document.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
const modal = wrap.closest(".modal-overlay");
if (modal && modal._pushFullscreen) modal._pushFullscreen();
const closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
closeBtn.onclick = (e) => {
e.stopPropagation();
history.back(); // popHandler will collapse
};
closeBtn.onclick = (e) => { e.stopPropagation(); history.back(); };
wrap.appendChild(closeBtn);
const parentOverlay = wrap.closest(".modal-overlay");
winPush("fsChart", () => {
wrap.classList.remove("chart-full");
closeBtn.remove();
});
winStack[winStack.length - 1].overlay = parentOverlay;
};
});
@ -2864,10 +2839,10 @@ async function showSessionDetail(sessionId) {
const del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
await DB.deleteGpsSession(sessionId);
document.querySelector(".modal-overlay")?.remove();
// Remove modal popstate handler, go to gps screen
history.replaceState({ screen: "gps" }, "");
renderScreen("gps", true);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("gps");
};
}, 100);
@ -4021,31 +3996,25 @@ function esc(str) {
// Start
// ---------------------------------------------------------------------------
// Global back navigation between screens
// Single global popstate handler
window.addEventListener("popstate", (e) => {
// Modal popHandler already handled this event — skip
if (_modalPopHandled) { _modalPopHandled = false; return; }
// If a modal is open, don't handle here (modal has its own handler)
if (document.querySelector(".modal-overlay")) return;
// Chart fullscreen on screen (not in modal) — collapse it
const fsChart = document.querySelector(".chart-full");
if (fsChart) {
fsChart.classList.remove("chart-full");
const btn = fsChart.querySelector(".chart-close-btn");
if (btn) btn.remove();
return;
}
// Stale modal/chart entries left after app restart — skip them
if (e.state && (e.state.modal || e.state.chartFull)) {
// closeModal used history.go(-N) — skip N popstate events
if (_skipPops > 0) { _skipPops--; return; }
// winStack has entries — pop the top one (close modal/fullscreen)
if (winPopTop()) return;
// Stale entries from previous app session — skip
if (e.state && (e.state.modal || e.state.chartFull || e.state.modalFs || e.state.win)) {
history.back();
return;
}
// Pop our nav stack and navigate to previous screen
// Navigate between screens via navStack
if (navStack.length > 1) {
navStack.pop();
persistNavStack();
const prev = navStack[navStack.length - 1];
renderScreen(prev, true);
renderScreen(navStack[navStack.length - 1], true);
} else if (e.state && e.state.screen) {
renderScreen(e.state.screen, true);
}