|
|
@@ -54,6 +54,12 @@
|
|
|
border-radius: 10px; display: flex; flex-direction: column;
|
|
|
box-shadow: 0 6px 24px rgba(0,0,0,.35); animation: winIn .3s ease both;
|
|
|
}
|
|
|
+ /* maximized: the frame disappears and the window IS the layer; its title
|
|
|
+ moves into the header (with a restore button) */
|
|
|
+ .win.max { border: 0; border-radius: 0; box-shadow: none; }
|
|
|
+ .win.max .win-resize { display: none; }
|
|
|
+ #maxinfo { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); }
|
|
|
+ #maxinfo .maxtitle { color: var(--fg); font-weight: 600; }
|
|
|
.win-bar {
|
|
|
display: flex; align-items: center; gap: 6px; padding: 7px 10px;
|
|
|
border-bottom: 1px solid var(--border); cursor: grab; user-select: none;
|
|
|
@@ -350,6 +356,8 @@
|
|
|
<body>
|
|
|
<header>
|
|
|
<h1>sm121</h1>
|
|
|
+ <div id="maxinfo" hidden><span>⛶</span><span class="maxtitle" id="max-title"></span>
|
|
|
+ <button class="tbtn" id="max-restore" title="restore window size">❐</button></div>
|
|
|
<div class="spacer"></div>
|
|
|
<div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
|
|
|
<button class="tbtn" id="theme">◐</button>
|
|
|
@@ -410,6 +418,7 @@ function sameSnapshot(a, b) {
|
|
|
// --- theme ---------------------------------------------------------------
|
|
|
const root = document.documentElement;
|
|
|
root.dataset.theme = localStorage.getItem("sm121-theme") || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
|
|
+$("#max-restore").onclick = () => { if (maxedHandle) maxedHandle.unmaximize(); };
|
|
|
$("#theme").onclick = () => {
|
|
|
root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
|
|
|
localStorage.setItem("sm121-theme", root.dataset.theme);
|
|
|
@@ -426,6 +435,38 @@ const winRegistry = new Set();
|
|
|
const logWins = new Map(); // attach_id -> { win, pre, buf, paused, ... }
|
|
|
const front = (el) => { el.style.zIndex = ++ztop; };
|
|
|
|
|
|
+// At most one maximized window: its title shows in the header with a
|
|
|
+// restore button (the window itself has no frame left to click).
|
|
|
+let maxedHandle = null;
|
|
|
+function showMaxTitle(h) {
|
|
|
+ if (maxedHandle && maxedHandle !== h) maxedHandle.unmaximize();
|
|
|
+ maxedHandle = h;
|
|
|
+ $("#max-title").textContent = h.title;
|
|
|
+ $("#maxinfo").hidden = false;
|
|
|
+}
|
|
|
+function hideMaxTitle(h) {
|
|
|
+ if (maxedHandle !== h) return;
|
|
|
+ maxedHandle = null;
|
|
|
+ $("#maxinfo").hidden = true;
|
|
|
+}
|
|
|
+
|
|
|
+// Windows keep px geometry, so a shrinking layer (dock appears, browser
|
|
|
+// resize) would push their bottom edge out of view — clamp them back in.
|
|
|
+function fitToLayer() {
|
|
|
+ const layer = $("#winlayer");
|
|
|
+ const W = layer.clientWidth, H = layer.clientHeight;
|
|
|
+ for (const h of winRegistry) {
|
|
|
+ const w = h.win;
|
|
|
+ if (!w.isConnected || w.classList.contains("max")) continue;
|
|
|
+ const left = parseFloat(w.style.left) || 0, top = parseFloat(w.style.top) || 0;
|
|
|
+ if (left + w.offsetWidth > W) w.style.width = Math.max(240, W - left) + "px";
|
|
|
+ if (top + w.offsetHeight > H) {
|
|
|
+ if (top > H - 120) w.style.top = Math.max(0, H - 200) + "px";
|
|
|
+ w.style.height = Math.max(140, H - (parseFloat(w.style.top) || 0)) + "px";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, removeOnClose }) {
|
|
|
const win = document.createElement("section");
|
|
|
win.className = "win" + (extraCls ? " " + extraCls : "");
|
|
|
@@ -445,38 +486,48 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
|
|
|
win.append(bar, body, rz);
|
|
|
$("#winlayer").appendChild(win);
|
|
|
const closed = { value: false };
|
|
|
- bar.querySelector('[data-act="min"]').onclick = () => { win.style.display = "none"; renderDock(); };
|
|
|
- // maximize = fill the window layer (minus the dock strip, which hosts the
|
|
|
- // + menu and holds the chips of minimized windows)
|
|
|
+ bar.querySelector('[data-act="min"]').onclick = () => {
|
|
|
+ unmax(); // minimize a normal-shaped window; the dock chip restores it
|
|
|
+ win.style.display = "none";
|
|
|
+ renderDock();
|
|
|
+ };
|
|
|
+ // maximize = drop the frame and fill the window layer (the dock is a
|
|
|
+ // sibling below it, so it stays usable); the title moves to the header
|
|
|
const maxBtn = bar.querySelector('[data-act="max"]');
|
|
|
let geom = null;
|
|
|
+ const unmax = () => {
|
|
|
+ if (!geom) return;
|
|
|
+ win.classList.remove("max");
|
|
|
+ Object.assign(win.style, geom);
|
|
|
+ geom = null;
|
|
|
+ maxBtn.textContent = "▢";
|
|
|
+ maxBtn.title = "maximize";
|
|
|
+ hideMaxTitle(handle);
|
|
|
+ };
|
|
|
maxBtn.onclick = () => {
|
|
|
front(win);
|
|
|
- if (geom) {
|
|
|
- Object.assign(win.style, geom);
|
|
|
- geom = null;
|
|
|
- maxBtn.textContent = "▢";
|
|
|
- maxBtn.title = "maximize";
|
|
|
- return;
|
|
|
- }
|
|
|
+ if (geom) { unmax(); return; }
|
|
|
geom = { left: win.style.left, top: win.style.top, width: win.style.width, height: win.style.height };
|
|
|
+ win.classList.add("max");
|
|
|
win.style.left = "0px";
|
|
|
win.style.top = "0px";
|
|
|
- win.style.width = $("#winlayer").clientWidth + "px";
|
|
|
- win.style.height = Math.max(200, $("#winlayer").clientHeight - 40) + "px";
|
|
|
+ win.style.width = "100%";
|
|
|
+ win.style.height = "100%";
|
|
|
maxBtn.textContent = "❐";
|
|
|
maxBtn.title = "restore size";
|
|
|
+ showMaxTitle(handle);
|
|
|
};
|
|
|
bar.querySelector('[data-act="close"]').onclick = () => {
|
|
|
if (removeOnClose) win.remove(); // log windows die with their attach
|
|
|
else win.style.display = "none";
|
|
|
closed.value = true;
|
|
|
+ hideMaxTitle(handle);
|
|
|
winRegistry.delete(handle);
|
|
|
renderDock();
|
|
|
if (onClosed) onClosed();
|
|
|
};
|
|
|
bar.addEventListener("pointerdown", (e) => {
|
|
|
- if (e.target.closest(".wbtn")) return;
|
|
|
+ if (e.target.closest(".wbtn") || win.classList.contains("max")) return;
|
|
|
front(win);
|
|
|
const ox = win.offsetLeft, oy = win.offsetTop; // baseline — read ONCE
|
|
|
drag(e.clientX, e.clientY, (dx, dy) => {
|
|
|
@@ -497,7 +548,7 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
|
|
|
const handle = {
|
|
|
win, body, title,
|
|
|
label: short || title,
|
|
|
- unmaximize() { if (geom) maxBtn.onclick(); },
|
|
|
+ unmaximize: unmax,
|
|
|
restore() { win.style.display = ""; closed.value = false; front(win); renderDock(); },
|
|
|
isMinimized: () => win.style.display === "none",
|
|
|
isClosed: () => closed.value,
|
|
|
@@ -534,6 +585,7 @@ function renderDock() {
|
|
|
plus.title = "new window";
|
|
|
plus.onclick = (e) => { e.stopPropagation(); toggleDockMenu(); };
|
|
|
dock.appendChild(plus);
|
|
|
+ fitToLayer(); // the dock just appeared/disappeared → the layer resized
|
|
|
}
|
|
|
let dockMenuEl = null;
|
|
|
function closeDockMenu() { if (dockMenuEl) { dockMenuEl.remove(); dockMenuEl = null; } }
|
|
|
@@ -1584,7 +1636,7 @@ function initManagedWindows() {
|
|
|
const W = innerWidth;
|
|
|
// vertical budget = the actual window layer (header/dock already excluded);
|
|
|
// +40 reserve for the dock, which appears when a window gets minimized
|
|
|
- const L = Math.max(420, $("#winlayer").clientHeight - 40);
|
|
|
+ const L = Math.max(420, $("#winlayer").clientHeight);
|
|
|
// topology map: the primary window (left); state/jobs stack on the right
|
|
|
const tw = Math.max(560, Math.floor(W * 0.58));
|
|
|
const rx = tw + 30, rw = Math.max(340, W - rx - 12);
|
|
|
@@ -1635,6 +1687,7 @@ async function boot() {
|
|
|
} catch {}
|
|
|
connectSSE();
|
|
|
// 30 s heartbeat refresh (mirrors the server poll; harmless if state is identical)
|
|
|
+ addEventListener("resize", fitToLayer);
|
|
|
setInterval(async () => {
|
|
|
try {
|
|
|
const res = await fetch("/api/state");
|