PageSourceSearch

https://difranzo.com/js/wm.js

js difranzo.com collected 2026-10-04 00:08:03 UTC 10,009 bytes, 259 lines download raw bytes

1/* ==========================================================================
2   wm.js — window manager.
3
4   Windows are absolutely positioned divs with a title bar you drag and a
5   resize bar you pull. The key (focused) window owns the URL, so every window
6   is a shareable link and the back button does what people expect.
7   ========================================================================== */
8(function (global) {
9  "use strict";
10  var el = U.el;
11
12  var apps = {};          // id -> definition
13  var open = [];          // live windows, back to front
14  var layer = null;
15  var z = 10;
16  var cascade = 0;
17  var routing = false;    // guard against hashchange feedback loops
18
19  function isSmall() {
20    return global.matchMedia("(max-width: 860px), (max-height: 520px)").matches;
21  }
22
23  function define(def) { apps[def.id] = def; }
24
25  function find(id) {
26    for (var i = 0; i < open.length; i++) if (open[i].id === id) return open[i];
27    return null;
28  }
29
30  function nextPos(w, h) {
31    var pad = 176, maxX = Math.max(pad, global.innerWidth - w - 90);
32    var maxY = Math.max(20, global.innerHeight - h - 70);
33    var x = pad + (cascade % 6) * 26;
34    var y = 26 + (cascade % 6) * 24;
35    cascade++;
36    return { x: Math.min(x, maxX), y: Math.min(y, maxY) };
37  }
38
39  function focus(win) {
40    if (!win) return;
41    open.forEach(function (w) {
42      w.el.classList.toggle("key", w === win);
43      if (isSmall()) w.el.classList.toggle("stack-hidden", w !== win);
44      else w.el.classList.remove("stack-hidden");
45    });
46    win.el.style.zIndex = ++z;
47    open.splice(open.indexOf(win), 1);
48    open.push(win);
49    Dock.sync();
50    setHash(win);
51  }
52
53  /* Writing location.hash fires hashchange on a LATER task, so the guard has
54     to be cleared by the listener rather than on the next line. */
55  function setHash(win) {
56    if (!win) return;
57    var h = "#/" + win.id + (win.route ? "/" + encodeURIComponent(win.route) : "");
58    if (location.hash !== h) { routing = true; location.hash = h; }
59  }
60
61  function openApp(id, route, opts) {
62    var def = apps[id];
63    if (!def) return null;
64    opts = opts || {};
65
66    var existing = find(id);
67    if (existing && def.singleton !== false) {
68      existing.el.classList.remove("minimized");
69      if (route != null && route !== existing.route && existing.navigate) {
70        existing.route = route;
71        existing.navigate(route);
72      } else if (route != null && !existing.navigate) {
73        existing.route = route;          /* still loading: it mounts at the new route */
74      }
75      focus(existing);
76      return existing;
77    }
78
79    var w = opts.w || def.w || 640, h = opts.h || def.h || 420;
80    w = Math.min(w, global.innerWidth - 40);
81    h = Math.min(h, global.innerHeight - 60);
82    var pos = (opts.x != null && opts.y != null) ? { x: opts.x, y: opts.y } : nextPos(w, h);
83
84    var label = el("span", { class: "tb-label", text: def.title });
85    var minBtn = el("button", { class: "tb-btn tb-min", title: "Miniaturise", "aria-label": "Miniaturise" }, [el("i")]);
86    var closeBtn = el("button", { class: "tb-btn tb-close", title: "Close", "aria-label": "Close" }, [el("i")]);
87    var title = el("div", { class: "win-title" }, [minBtn, label, closeBtn]);
88    var body = el("div", { class: "win-body" });
89    var resize = el("div", { class: "win-resize" }, [
90      el("span", { class: "grip" }), el("span"), el("span", { class: "grip" })
91    ]);
92    var node = el("div", { class: "win", "data-app": id, role: "dialog", "aria-label": def.title },
93      [title, body, resize]);
94    node.style.left = pos.x + "px";
95    node.style.top = pos.y + "px";
96    node.style.width = w + "px";
97    node.style.height = h + "px";
98
99    var win = {
100      id: id, el: node, body: body, route: route || "",
101      setTitle: function (t) { label.textContent = t; },
102      close: function () { closeWin(win); },
103      focus: function () { focus(win); }
104    };
105
106    layer.appendChild(node);
107    open.push(win);
108
109    node.addEventListener("mousedown", function () { if (open[open.length - 1] !== win) focus(win); }, true);
110    node.addEventListener("touchstart", function () { if (open[open.length - 1] !== win) focus(win); }, { capture: true, passive: true });
111    closeBtn.addEventListener("click", function (e) { e.stopPropagation(); closeWin(win); });
112    minBtn.addEventListener("click", function (e) {
113      e.stopPropagation();
114      node.classList.add("minimized");
115      Observer.record("window miniaturised", def.title);
116      var next = open.filter(function (o) { return !o.el.classList.contains("minimized"); }).pop();
117      if (next) focus(next); else Dock.sync();
118    });
119
120    drag(title, win);
121    sizer(resize, win);
122
123    if (global.Need) Need.mount(def, body, win); else def.mount(body, win);
124    focus(win);
125    Observer.record("opened", def.title + (route ? " → " + route : ""));
126    return win;
127  }
128
129  function closeWin(win) {
130    if (win.onClose) try { win.onClose(); } catch (e) {}
131    leaveGhost(win);
132    win.el.remove();
133    open.splice(open.indexOf(win), 1);
134    Observer.record("closed", (apps[win.id] || {}).title || win.id);
135    var next = open[open.length - 1];
136    if (next) focus(next);
137    else { routing = true; location.hash = ""; routing = false; Dock.sync(); }
138  }
139
140  /* A closed window leaves an outline where it stood. It fades most of the
141     way out and then stops — the machine does not entirely let go. Ghosts
142     are capped so a long session does not accumulate a fog. */
143  function leaveGhost(win) {
144    var layer = document.getElementById("ghosts");
145    if (!layer || document.body.classList.contains("no-fx")) return;
146    if (win.el.classList.contains("minimized")) return;
147    var r = win.el.getBoundingClientRect();
148    if (r.width < 40) return;
149    var g = el("div", { class: "ghost" }, [
150      el("div", { class: "g-title", text: (apps[win.id] || {}).title || "" })
151    ]);
152    g.style.left = r.left + "px";
153    g.style.top = r.top + "px";
154    g.style.width = r.width + "px";
155    g.style.height = r.height + "px";
156    layer.appendChild(g);
157    var keep = global.FX ? FX.num("ghosts.keep", 6) : 6;
158    while (layer.children.length > keep) layer.firstChild.remove();
159  }
160
161  /* --- pointer helpers ---------------------------------------------------- */
162  function pointer(e) {
163    var p = e.touches ? e.touches[0] : e;
164    return { x: p.clientX, y: p.clientY };
165  }
166  function gesture(handle, onStart, onMove) {
167    function down(e) {
168      if (e.button != null && e.button !== 0) return;
169      if (e.target.closest && e.target.closest("button")) return;
170      var start = pointer(e), ctx = onStart(start);
171      if (!ctx) return;
172      e.preventDefault();
173      function move(ev) { onMove(ctx, pointer(ev), start); }
174      function up() {
175        document.removeEventListener("mousemove", move);
176        document.removeEventListener("mouseup", up);
177        document.removeEventListener("touchmove", move);
178        document.removeEventListener("touchend", up);
179      }
180      document.addEventListener("mousemove", move);
181      document.addEventListener("mouseup", up);
182      document.addEventListener("touchmove", move, { passive: false });
183      document.addEventListener("touchend", up);
184    }
185    handle.addEventListener("mousedown", down);
186    handle.addEventListener("touchstart", down, { passive: false });
187  }
188
189  function drag(handle, win) {
190    gesture(handle, function () {
191      if (isSmall()) return null;
192      focus(win);
193      return { x0: win.el.offsetLeft, y0: win.el.offsetTop };
194    }, function (ctx, p, start) {
195      var x = ctx.x0 + (p.x - start.x), y = ctx.y0 + (p.y - start.y);
196      x = Math.max(-win.el.offsetWidth + 90, Math.min(x, global.innerWidth - 60));
197      y = Math.max(0, Math.min(y, global.innerHeight - 30));
198      win.el.style.left = x + "px";
199      win.el.style.top = y + "px";
200    });
201  }
202
203  function sizer(bar, win) {
204    var mode = "s";
205    bar.addEventListener("mousedown", function (e) {
206      mode = e.target.classList.contains("grip")
207        ? (e.target === bar.firstElementChild ? "sw" : "se") : "s";
208    });
209    bar.addEventListener("touchstart", function () { mode = "se"; }, { passive: true });
210    gesture(bar, function () {
211      if (isSmall()) return null;
212      return { w: win.el.offsetWidth, h: win.el.offsetHeight, x: win.el.offsetLeft, m: mode };
213    }, function (ctx, p, start) {
214      var dx = p.x - start.x, dy = p.y - start.y;
215      var h = Math.max(140, ctx.h + dy);
216      win.el.style.height = h + "px";
217      if (ctx.m === "se") win.el.style.width = Math.max(280, ctx.w + dx) + "px";
218      if (ctx.m === "sw") {
219        var w = Math.max(280, ctx.w - dx);
220        win.el.style.width = w + "px";
221        win.el.style.left = (ctx.x + (ctx.w - w)) + "px";
222      }
223    });
224  }
225
226  /* --- routing ------------------------------------------------------------ */
227  function fromHash() {
228    if (routing) { routing = false; return; }
229    var h = location.hash.replace(/^#\/?/, "");
230    if (!h) return;
231    var parts = h.split("/");
232    var id = parts.shift();
233    var route = parts.join("/");
234    try { route = decodeURIComponent(route); } catch (e) {}
235    if (apps[id]) openApp(id, route);
236  }
237
238  function init(container) {
239    layer = container;
240    global.addEventListener("hashchange", fromHash);
241    global.addEventListener("keydown", function (e) {
242      var top = open[open.length - 1];
243      if (e.key === "Escape" && top && !e.target.closest("input, textarea")) closeWin(top);
244      if (e.key === "w" && (e.metaKey || e.ctrlKey) && top) { e.preventDefault(); closeWin(top); }
245    });
246    global.addEventListener("resize", U.debounce(function () {
247      var top = open[open.length - 1];
248      if (top) focus(top);
249    }, 160));
250  }
251
252  global.WM = {
253    define: define, open: openApp, close: closeWin, focus: focus,
254    init: init, fromHash: fromHash, apps: apps, setRoute: setHash,
255    isOpen: function (id) { return !!find(id); },
256    list: function () { return open.slice(); },
257    def: function (id) { return apps[id]; }
258  };
259})(window);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.