PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.0
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / widget-heartbeat.js

widget-heartbeat.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.0, at assets/js/widget-heartbeat.js

539 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function() {
2 "use strict";
3 const SHARED_STORES_SLOT = "__desktopModeSharedStores";
4 function resolveSlot() {
5 const w2 = window;
6 let slot = w2[SHARED_STORES_SLOT];
7 if (!slot) {
8 slot = /* @__PURE__ */ new Map();
9 w2[SHARED_STORES_SLOT] = slot;
10 }
11 return slot;
12 }
13 function createSharedStore(key, initialState) {
14 const slot = resolveSlot();
15 let record = slot.get(key);
16 if (!record) {
17 record = {
18 state: initialState(),
19 listeners: /* @__PURE__ */ new Set(),
20 rebuild: initialState
21 };
22 slot.set(key, record);
23 }
24 const handle = {
25 // `record.state` is the live reference. The getter on the
26 // `state` field reads the latest value even if `reset()`
27 // reassigned it to a fresh object.
28 get state() {
29 return record.state;
30 },
31 set state(next) {
32 record.state = next;
33 },
34 getState() {
35 return record.state;
36 },
37 notify() {
38 for (const cb of Array.from(record.listeners)) {
39 try {
40 cb(record.state);
41 } catch (err) {
42 console.error(
43 `[desktop-mode/shared-store:${key}] subscriber threw:`,
44 err
45 );
46 }
47 }
48 },
49 subscribe(cb) {
50 record.listeners.add(cb);
51 return () => {
52 record.listeners.delete(cb);
53 };
54 },
55 setState(patch) {
56 const cur = record.state;
57 if (typeof cur !== "object" || cur === null) {
58 console.warn(
59 `[desktop-mode/shared-store:${key}] setState called on a primitive store; use the state setter instead.`
60 );
61 return;
62 }
63 Object.assign(cur, patch);
64 handle.notify();
65 },
66 reset() {
67 const fresh = record.rebuild();
68 const cur = record.state;
69 if (typeof cur === "object" && cur !== null && typeof fresh === "object" && fresh !== null) {
70 const target = cur;
71 for (const k of Object.keys(target)) {
72 delete target[k];
73 }
74 Object.assign(target, fresh);
75 } else {
76 record.state = fresh;
77 }
78 record.listeners.clear();
79 }
80 };
81 return handle;
82 }
83 async function loadPixi() {
84 const wp = window.wp;
85 const fn = wp?.desktop?.loadModules;
86 if (typeof fn !== "function") {
87 throw new Error(
88 "wp.desktop.loadModules is not available — main shell may not have booted yet."
89 );
90 }
91 await fn(["pixijs"]);
92 }
93 const wpBeatStore = createSharedStore(
94 "desktop-mode/heartbeat-widget/wp-beats",
95 () => ({
96 lastTickAt: 0,
97 lastSendAt: 0,
98 intervalSecs: 15,
99 tickSeq: 0,
100 booted: false
101 })
102 );
103 function bootWpBeatTracker() {
104 const s = wpBeatStore;
105 if (s.state.booted) {
106 return;
107 }
108 s.state.booted = true;
109 s.state.intervalSecs = wpHeartbeatInterval();
110 s.state.lastTickAt = performance.now();
111 const jq = window.jQuery;
112 if (!jq) {
113 return;
114 }
115 const $doc = jq(document);
116 $doc.on("heartbeat-send", () => {
117 s.state.lastSendAt = performance.now();
118 });
119 $doc.on("heartbeat-tick", () => {
120 s.state.lastTickAt = performance.now();
121 s.state.intervalSecs = wpHeartbeatInterval();
122 s.state.tickSeq += 1;
123 s.notify();
124 });
125 }
126 const HEART_PALETTE = {
127 bright: 16731501,
128 hi: 16754104
129 };
130 const HEART_COLOR_REST = HEART_PALETTE.bright;
131 const HEART_COLOR_BEAT = HEART_PALETTE.hi;
132 const HEART_SIZE = 52;
133 const mount = async (container, ctx) => {
134 try {
135 await loadPixi();
136 } catch (e) {
137 renderFallback(container, e.message);
138 return () => void 0;
139 }
140 return mountWithPixi(container, ctx);
141 };
142 function logoUrl(ctx) {
143 const base = (ctx?.pluginUrl ?? "").replace(/\/+$/, "");
144 return `${base}/assets/images/wp-logo.png`;
145 }
146 function renderFallback(container, message) {
147 container.classList.add("desktop-mode-widget-heartbeat");
148 container.classList.add("desktop-mode-widget-heartbeat--fallback");
149 const wrap = document.createElement("div");
150 wrap.className = "desktop-mode-widget-heartbeat__fallback";
151 wrap.textContent = message || "Could not load animation engine.";
152 container.appendChild(wrap);
153 }
154 const FRAME_HEIGHT_WITH_HEART = 230;
155 const FRAME_HEIGHT_NO_HEART = 88;
156 async function mountWithPixi(container, ctx) {
157 const pixi = window.PIXI;
158 if (!pixi) {
159 renderFallback(container, "PIXI not available.");
160 return () => void 0;
161 }
162 container.classList.add("desktop-mode-widget-heartbeat");
163 let showHeart = ctx.storage.get("showHeart") ?? true;
164 if (!showHeart) {
165 container.classList.add("desktop-mode-widget-heartbeat--no-heart");
166 }
167 const stage = document.createElement("div");
168 stage.className = "desktop-mode-widget-heartbeat__stage";
169 container.appendChild(stage);
170 const meta = document.createElement("div");
171 meta.className = "desktop-mode-widget-heartbeat__meta";
172 const label = document.createElement("div");
173 label.className = "desktop-mode-widget-heartbeat__label";
174 label.textContent = "Next beat in";
175 meta.appendChild(label);
176 const remaining = document.createElement("div");
177 remaining.className = "desktop-mode-widget-heartbeat__remaining";
178 remaining.textContent = "—";
179 meta.appendChild(remaining);
180 container.appendChild(meta);
181 const bar = document.createElement("div");
182 bar.className = "desktop-mode-widget-heartbeat__bar";
183 const fill = document.createElement("div");
184 fill.className = "desktop-mode-widget-heartbeat__bar-fill";
185 bar.appendChild(fill);
186 container.appendChild(bar);
187 const app = new pixi.Application();
188 await app.init({
189 resizeTo: stage,
190 backgroundAlpha: 0,
191 antialias: true,
192 autoDensity: true,
193 resolution: Math.min(window.devicePixelRatio || 1, 2)
194 });
195 stage.appendChild(app.canvas);
196 const halo = buildHalo(pixi);
197 const heart = buildHeart(pixi);
198 const logo = buildLogoSprite(pixi, logoUrl(ctx));
199 app.stage.addChild(halo);
200 app.stage.addChild(heart);
201 heart.addChild(logo);
202 const centre = () => {
203 halo.x = app.screen.width / 2;
204 halo.y = app.screen.height / 2;
205 heart.x = app.screen.width / 2;
206 heart.y = app.screen.height / 2;
207 };
208 centre();
209 const ro = new ResizeObserver(() => centre());
210 ro.observe(stage);
211 bootWpBeatTracker();
212 let pulseAccum = 0;
213 let bigBeatT = 0;
214 let glow = 0;
215 let lastSeenSeq = wpBeatStore.state.tickSeq;
216 const unsubscribe = wpBeatStore.subscribe((s) => {
217 if (s.tickSeq !== lastSeenSeq) {
218 lastSeenSeq = s.tickSeq;
219 bigBeatT = 1;
220 glow = 1;
221 }
222 });
223 const jq = window.jQuery;
224 let simHandle = null;
225 if (!jq) {
226 simHandle = setInterval(() => {
227 wpBeatStore.state.lastTickAt = performance.now();
228 wpBeatStore.state.tickSeq += 1;
229 wpBeatStore.notify();
230 }, wpBeatStore.state.intervalSecs * 1e3);
231 }
232 const detach = () => {
233 unsubscribe();
234 if (simHandle !== null) {
235 clearInterval(simHandle);
236 }
237 };
238 const tick = () => {
239 const dt = app.ticker.deltaMS / 1e3;
240 pulseAccum += dt;
241 const restPhase = pulseAccum / 4 * Math.PI * 2;
242 const restingScaleBase = 1 + 8e-3 * Math.sin(restPhase);
243 let restingScale = restingScaleBase;
244 let squishX = 0;
245 let squishY = 0;
246 if (bigBeatT > 0) {
247 bigBeatT = Math.max(0, bigBeatT - dt / 0.9);
248 const t = 1 - bigBeatT;
249 let env;
250 if (t < 0.1) {
251 env = 0.55 * (t / 0.1);
252 } else if (t < 0.28) {
253 env = 0.55 - 0.5 * ((t - 0.1) / 0.18);
254 } else if (t < 0.55) {
255 env = 0.05 + 0.1 * Math.sin((t - 0.28) / 0.27 * Math.PI);
256 } else {
257 const k = (t - 0.55) / 0.45;
258 env = 0.05 * (1 - k) * Math.exp(-2.5 * k);
259 }
260 restingScale += env;
261 if (t < 0.2) {
262 const sP = Math.sin(t / 0.2 * Math.PI);
263 squishX = 0.1 * sP;
264 squishY = -0.05 * sP;
265 }
266 }
267 heart.scale.x = restingScale * (1 + squishX);
268 heart.scale.y = restingScale * (1 + squishY);
269 glow = Math.max(0, glow - dt / 1.2);
270 halo.alpha = 0.1 + glow * 0.55;
271 const haloScale = 1 + glow * 0.15;
272 halo.scale.set(haloScale);
273 heart.tint = lerpColor(
274 HEART_COLOR_REST,
275 HEART_COLOR_BEAT,
276 Math.min(1, glow * 0.6 + bigBeatT * 0.4)
277 );
278 const elapsed = (performance.now() - wpBeatStore.state.lastTickAt) / 1e3;
279 const intervalSecs = wpBeatStore.state.intervalSecs;
280 const progress = clamp(elapsed / Math.max(intervalSecs, 1), 0, 1);
281 fill.style.width = `${(progress * 100).toFixed(1)}%`;
282 const remainSecs = Math.max(0, intervalSecs - elapsed);
283 remaining.textContent = `${remainSecs.toFixed(1)}s`;
284 };
285 app.ticker.add(tick);
286 const resyncCanvasToStage = () => {
287 requestAnimationFrame(() => {
288 try {
289 app.resize?.();
290 } catch (_e) {
291 try {
292 const sw = stage.clientWidth;
293 const sh = stage.clientHeight;
294 if (sw > 0 && sh > 0) {
295 app.renderer.resize(sw, sh);
296 }
297 } catch (_err) {
298 }
299 }
300 centre();
301 });
302 };
303 const onContextMenu = (e) => {
304 e.preventDefault();
305 e.stopPropagation();
306 openHeartbeatMenu(e, showHeart, (next) => {
307 showHeart = next;
308 ctx.storage.set("showHeart", next);
309 applyHeartVisibility(container, next);
310 if (next) {
311 resyncCanvasToStage();
312 }
313 });
314 };
315 container.addEventListener("contextmenu", onContextMenu);
316 applyHeartVisibility(container, showHeart);
317 if (showHeart) {
318 resyncCanvasToStage();
319 }
320 return () => {
321 container.removeEventListener("contextmenu", onContextMenu);
322 detach();
323 ro.disconnect();
324 app.ticker.remove(tick);
325 try {
326 app.canvas?.remove();
327 } catch {
328 }
329 container.classList.remove("desktop-mode-widget-heartbeat");
330 container.classList.remove("desktop-mode-widget-heartbeat--no-heart");
331 };
332 }
333 function applyHeartVisibility(container, showHeart) {
334 container.classList.toggle("desktop-mode-widget-heartbeat--no-heart", !showHeart);
335 const card = container.closest(".desktop-mode-widgets__card");
336 if (card) {
337 card.classList.add("desktop-mode-widgets__card--heartbeat");
338 const h = showHeart ? FRAME_HEIGHT_WITH_HEART : FRAME_HEIGHT_NO_HEART;
339 card.style.height = `${h}px`;
340 }
341 }
342 function openHeartbeatMenu(e, showHeart, onToggle) {
343 document.querySelectorAll(".desktop-mode-widget-heartbeat__menu").forEach((el) => el.remove());
344 const menu = document.createElement("wpd-context-menu");
345 menu.className = "desktop-mode-widget-heartbeat__menu";
346 menu.setAttribute("open", "");
347 menu.style.position = "fixed";
348 menu.style.left = `${e.clientX}px`;
349 menu.style.top = `${e.clientY}px`;
350 menu.style.zIndex = "10500";
351 const opt = document.createElement("wpd-context-menu-option");
352 opt.setAttribute("value", "show-heart");
353 if (showHeart) {
354 opt.setAttribute("checked", "");
355 }
356 opt.textContent = "Show heart";
357 menu.appendChild(opt);
358 const close = () => {
359 menu.remove();
360 document.removeEventListener("pointerdown", onOutside, true);
361 document.removeEventListener("keydown", onKey, true);
362 };
363 const onOutside = (ev) => {
364 if (!menu.contains(ev.target)) {
365 close();
366 }
367 };
368 const onKey = (ev) => {
369 if (ev.key === "Escape") {
370 close();
371 }
372 };
373 menu.addEventListener("wpd-context-menu-pick", () => {
374 onToggle(!showHeart);
375 close();
376 });
377 document.body.appendChild(menu);
378 const rect = menu.getBoundingClientRect();
379 if (rect.right > window.innerWidth) {
380 menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 8)}px`;
381 }
382 if (rect.bottom > window.innerHeight) {
383 menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 8)}px`;
384 }
385 document.addEventListener("pointerdown", onOutside, true);
386 document.addEventListener("keydown", onKey, true);
387 }
388 function buildHalo(pixi) {
389 const g = new pixi.Graphics();
390 const radii = [HEART_SIZE * 1.2, HEART_SIZE * 1.05, HEART_SIZE * 0.85];
391 const alphas = [0.12, 0.18, 0.28];
392 radii.forEach((r, i) => {
393 g.circle(0, 0, r);
394 g.fill({ color: HEART_COLOR_BEAT, alpha: alphas[i] });
395 });
396 g.alpha = 0.1;
397 return g;
398 }
399 function heartPath(scaleMul = 1) {
400 const samples = 240;
401 const pts = [];
402 const s = HEART_SIZE / 17 * scaleMul;
403 for (let i = 0; i <= samples; i++) {
404 const t = i / samples * Math.PI * 2;
405 const x = 16 * 1.08 * Math.sin(t) ** 3;
406 const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
407 pts.push(x * s, y * s);
408 }
409 return pts;
410 }
411 function buildHeart(pixi) {
412 const wrap = new pixi.Container();
413 const bounds = heartBoundingY();
414 const shadow = new pixi.Graphics();
415 shadow.poly(heartPath(1.05));
416 shadow.fill({ color: 0, alpha: 0.55 });
417 shadow.y = 5;
418 shadow.alpha = 0.55;
419 wrap.addChild(shadow);
420 const gradientCanvas = makeGradientCanvas();
421 const gradientTexture = pixi.Texture.from(gradientCanvas);
422 const gradientSprite = new pixi.Sprite(gradientTexture);
423 const heartHeight = bounds.maxY - bounds.minY;
424 const overscan = HEART_SIZE * 2.5;
425 gradientSprite.width = overscan;
426 gradientSprite.height = heartHeight;
427 gradientSprite.x = -overscan / 2;
428 gradientSprite.y = bounds.minY;
429 const mask = new pixi.Graphics();
430 mask.poly(heartPath(1));
431 mask.fill({ color: 16777215, alpha: 1 });
432 gradientSprite.mask = mask;
433 wrap.addChild(mask);
434 wrap.addChild(gradientSprite);
435 const hi1 = new pixi.Graphics();
436 hi1.ellipse(
437 -HEART_SIZE * 0.32,
438 -HEART_SIZE * 0.5,
439 HEART_SIZE * 0.18,
440 HEART_SIZE * 0.1
441 );
442 hi1.fill({ color: 16777215, alpha: 0.45 });
443 hi1.rotation = -0.5;
444 wrap.addChild(hi1);
445 const hi2 = new pixi.Graphics();
446 hi2.ellipse(
447 HEART_SIZE * 0.2,
448 -HEART_SIZE * 0.38,
449 HEART_SIZE * 0.09,
450 HEART_SIZE * 0.05
451 );
452 hi2.fill({ color: 16777215, alpha: 0.22 });
453 hi2.rotation = 0.4;
454 wrap.addChild(hi2);
455 const outline = new pixi.Graphics();
456 outline.poly(heartPath(1));
457 outline.stroke({ color: 16777215, alpha: 0.18, width: 1 });
458 wrap.addChild(outline);
459 return wrap;
460 }
461 function heartBoundingY() {
462 const s = HEART_SIZE / 17;
463 return {
464 minY: -15 * s,
465 maxY: 9 * s
466 };
467 }
468 function makeGradientCanvas() {
469 const c = document.createElement("canvas");
470 c.width = 2;
471 c.height = 512;
472 const ctx = c.getContext("2d");
473 if (!ctx) {
474 return c;
475 }
476 const grad = ctx.createLinearGradient(0, 0, 0, 512);
477 grad.addColorStop(0, "#ffd6e3");
478 grad.addColorStop(0.18, "#ff8ba3");
479 grad.addColorStop(0.42, "#ff4d6d");
480 grad.addColorStop(0.72, "#9a1f3d");
481 grad.addColorStop(1, "#3d061a");
482 ctx.fillStyle = grad;
483 ctx.fillRect(0, 0, 2, 512);
484 return c;
485 }
486 function buildLogoSprite(pixi, url) {
487 const sprite = new pixi.Sprite();
488 sprite.anchor.set(0.5);
489 sprite.alpha = 0.95;
490 sprite.y = HEART_SIZE * 0.08;
491 const targetWidth = HEART_SIZE * 0.92;
492 pixi.Assets.load(url).then((texture) => {
493 sprite.texture = texture;
494 const scale = targetWidth / Math.max(1, texture.width);
495 sprite.scale.set(scale);
496 }).catch(() => {
497 });
498 return sprite;
499 }
500 function wpHeartbeatInterval() {
501 const wp = window.wp;
502 try {
503 const fn = wp?.heartbeat?.interval;
504 if (typeof fn === "function") {
505 const v = Number(fn());
506 if (Number.isFinite(v) && v > 0) {
507 return v;
508 }
509 }
510 } catch (e) {
511 }
512 return 15;
513 }
514 function lerpColor(a, b, t) {
515 const ar = Math.trunc(a / 65536) % 256;
516 const ag = Math.trunc(a / 256) % 256;
517 const ab = a % 256;
518 const br = Math.trunc(b / 65536) % 256;
519 const bg = Math.trunc(b / 256) % 256;
520 const bb = b % 256;
521 const r = Math.round(ar + (br - ar) * t);
522 const g = Math.round(ag + (bg - ag) * t);
523 const bv = Math.round(ab + (bb - ab) * t);
524 return r * 65536 + g * 256 + bv;
525 }
526 function clamp(v, lo, hi) {
527 if (v < lo) {
528 return lo;
529 }
530 if (v > hi) {
531 return hi;
532 }
533 return v;
534 }
535 const w = window;
536 w.desktopModeWidgets = w.desktopModeWidgets || {};
537 w.desktopModeWidgets["desktop-mode/heartbeat"] = mount;
538 })();
539