PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
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.6, at assets/js/widget-heartbeat.js

596 lines 18.2 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 rim: 6950693,
128 bright: 16731501,
129 hi: 16754104
130 };
131 const HEART_COLOR_REST = HEART_PALETTE.bright;
132 const HEART_COLOR_BEAT = HEART_PALETTE.hi;
133 const HEART_SIZE = 52;
134 const mount = async (container, ctx) => {
135 try {
136 await loadPixi();
137 } catch (e) {
138 renderFallback(container, e.message);
139 return () => void 0;
140 }
141 return mountWithPixi(container, ctx);
142 };
143 function logoUrl(ctx) {
144 const base = (ctx?.pluginUrl ?? "").replace(/\/+$/, "");
145 return `${base}/assets/images/wp-logo.png`;
146 }
147 function renderFallback(container, message) {
148 container.classList.add("desktop-mode-widget-heartbeat");
149 container.classList.add("desktop-mode-widget-heartbeat--fallback");
150 const wrap = document.createElement("div");
151 wrap.className = "desktop-mode-widget-heartbeat__fallback";
152 wrap.textContent = message || "Could not load animation engine.";
153 container.appendChild(wrap);
154 }
155 const FRAME_HEIGHT_WITH_HEART = 230;
156 const FRAME_HEIGHT_NO_HEART = 88;
157 async function mountWithPixi(container, ctx) {
158 const pixi = window.PIXI;
159 if (!pixi) {
160 renderFallback(container, "PIXI not available.");
161 return () => void 0;
162 }
163 container.classList.add("desktop-mode-widget-heartbeat");
164 let showHeart = ctx.storage.get("showHeart") ?? true;
165 if (!showHeart) {
166 container.classList.add("desktop-mode-widget-heartbeat--no-heart");
167 }
168 const stage = document.createElement("div");
169 stage.className = "desktop-mode-widget-heartbeat__stage";
170 container.appendChild(stage);
171 const meta = document.createElement("div");
172 meta.className = "desktop-mode-widget-heartbeat__meta";
173 const label = document.createElement("div");
174 label.className = "desktop-mode-widget-heartbeat__label";
175 label.textContent = "Next beat in";
176 meta.appendChild(label);
177 const remaining = document.createElement("div");
178 remaining.className = "desktop-mode-widget-heartbeat__remaining";
179 remaining.textContent = "—";
180 meta.appendChild(remaining);
181 container.appendChild(meta);
182 const bar = document.createElement("div");
183 bar.className = "desktop-mode-widget-heartbeat__bar";
184 const fill = document.createElement("div");
185 fill.className = "desktop-mode-widget-heartbeat__bar-fill";
186 bar.appendChild(fill);
187 container.appendChild(bar);
188 const app = new pixi.Application();
189 await app.init({
190 resizeTo: stage,
191 backgroundAlpha: 0,
192 antialias: true,
193 autoDensity: true,
194 resolution: Math.min(window.devicePixelRatio || 1, 2)
195 });
196 stage.appendChild(app.canvas);
197 const halo = buildHalo(pixi);
198 const { view: heart, body: heartBody } = buildHeart(pixi);
199 const logo = buildLogoSprite(pixi, logoUrl(ctx));
200 app.stage.addChild(halo);
201 app.stage.addChild(heart);
202 heart.addChild(logo);
203 const centre = () => {
204 halo.x = app.screen.width / 2;
205 halo.y = app.screen.height / 2;
206 heart.x = app.screen.width / 2;
207 heart.y = app.screen.height / 2;
208 };
209 centre();
210 const ro = new ResizeObserver(() => centre());
211 ro.observe(stage);
212 bootWpBeatTracker();
213 let pulseAccum = 0;
214 let bigBeatT = 0;
215 let glow = 0;
216 let lastSeenSeq = wpBeatStore.state.tickSeq;
217 const unsubscribe = wpBeatStore.subscribe((s) => {
218 if (s.tickSeq !== lastSeenSeq) {
219 lastSeenSeq = s.tickSeq;
220 bigBeatT = 1;
221 glow = 1;
222 }
223 });
224 const jq = window.jQuery;
225 let simHandle = null;
226 if (!jq) {
227 simHandle = setInterval(() => {
228 wpBeatStore.state.lastTickAt = performance.now();
229 wpBeatStore.state.tickSeq += 1;
230 wpBeatStore.notify();
231 }, wpBeatStore.state.intervalSecs * 1e3);
232 }
233 const detach = () => {
234 unsubscribe();
235 if (simHandle !== null) {
236 clearInterval(simHandle);
237 }
238 };
239 const tick = () => {
240 const dt = app.ticker.deltaMS / 1e3;
241 pulseAccum += dt;
242 const restPhase = pulseAccum / 4 * Math.PI * 2;
243 const restingScaleBase = 1 + 8e-3 * Math.sin(restPhase);
244 let restingScale = restingScaleBase;
245 let squishX = 0;
246 let squishY = 0;
247 if (bigBeatT > 0) {
248 bigBeatT = Math.max(0, bigBeatT - dt / 0.9);
249 const t = 1 - bigBeatT;
250 let env;
251 if (t < 0.1) {
252 env = 0.55 * (t / 0.1);
253 } else if (t < 0.28) {
254 env = 0.55 - 0.5 * ((t - 0.1) / 0.18);
255 } else if (t < 0.55) {
256 env = 0.05 + 0.1 * Math.sin((t - 0.28) / 0.27 * Math.PI);
257 } else {
258 const k = (t - 0.55) / 0.45;
259 env = 0.05 * (1 - k) * Math.exp(-2.5 * k);
260 }
261 restingScale += env;
262 if (t < 0.2) {
263 const sP = Math.sin(t / 0.2 * Math.PI);
264 squishX = 0.1 * sP;
265 squishY = -0.05 * sP;
266 }
267 }
268 heart.scale.x = restingScale * (1 + squishX);
269 heart.scale.y = restingScale * (1 + squishY);
270 glow = Math.max(0, glow - dt / 1.2);
271 halo.alpha = 0.1 + glow * 0.55;
272 const haloScale = 1 + glow * 0.15;
273 halo.scale.set(haloScale);
274 heartBody.tint = lerpColor(
275 HEART_COLOR_REST,
276 HEART_COLOR_BEAT,
277 Math.min(1, glow * 0.6 + bigBeatT * 0.4)
278 );
279 const elapsed = (performance.now() - wpBeatStore.state.lastTickAt) / 1e3;
280 const intervalSecs = wpBeatStore.state.intervalSecs;
281 const progress = clamp(elapsed / Math.max(intervalSecs, 1), 0, 1);
282 fill.style.width = `${(progress * 100).toFixed(1)}%`;
283 const remainSecs = Math.max(0, intervalSecs - elapsed);
284 remaining.textContent = `${remainSecs.toFixed(1)}s`;
285 };
286 app.ticker.add(tick);
287 const resyncCanvasToStage = () => {
288 requestAnimationFrame(() => {
289 try {
290 app.resize?.();
291 } catch (_e) {
292 try {
293 const sw = stage.clientWidth;
294 const sh = stage.clientHeight;
295 if (sw > 0 && sh > 0) {
296 app.renderer.resize(sw, sh);
297 }
298 } catch (_err) {
299 }
300 }
301 centre();
302 });
303 };
304 const onContextMenu = (e) => {
305 e.preventDefault();
306 e.stopPropagation();
307 openHeartbeatMenu(e, showHeart, (next) => {
308 showHeart = next;
309 ctx.storage.set("showHeart", next);
310 applyHeartVisibility(container, next);
311 if (next) {
312 resyncCanvasToStage();
313 }
314 });
315 };
316 container.addEventListener("contextmenu", onContextMenu);
317 applyHeartVisibility(container, showHeart);
318 if (showHeart) {
319 resyncCanvasToStage();
320 }
321 return () => {
322 container.removeEventListener("contextmenu", onContextMenu);
323 detach();
324 ro.disconnect();
325 app.ticker.remove(tick);
326 try {
327 app.canvas?.remove();
328 } catch {
329 }
330 container.classList.remove("desktop-mode-widget-heartbeat");
331 container.classList.remove("desktop-mode-widget-heartbeat--no-heart");
332 };
333 }
334 function applyHeartVisibility(container, showHeart) {
335 container.classList.toggle("desktop-mode-widget-heartbeat--no-heart", !showHeart);
336 const card = container.closest(".desktop-mode-widgets__card");
337 if (card) {
338 card.classList.add("desktop-mode-widgets__card--heartbeat");
339 const h = showHeart ? FRAME_HEIGHT_WITH_HEART : FRAME_HEIGHT_NO_HEART;
340 card.style.height = `${h}px`;
341 }
342 }
343 function openHeartbeatMenu(e, showHeart, onToggle) {
344 document.querySelectorAll(".desktop-mode-widget-heartbeat__menu").forEach((el) => el.remove());
345 const menu = document.createElement("wpd-context-menu");
346 menu.className = "desktop-mode-widget-heartbeat__menu";
347 menu.setAttribute("open", "");
348 menu.style.position = "fixed";
349 menu.style.left = `${e.clientX}px`;
350 menu.style.top = `${e.clientY}px`;
351 menu.style.zIndex = "10500";
352 const opt = document.createElement("wpd-context-menu-option");
353 opt.setAttribute("value", "show-heart");
354 if (showHeart) {
355 opt.setAttribute("checked", "");
356 }
357 opt.textContent = "Show heart";
358 menu.appendChild(opt);
359 const close = () => {
360 menu.remove();
361 document.removeEventListener("pointerdown", onOutside, true);
362 document.removeEventListener("keydown", onKey, true);
363 };
364 const onOutside = (ev) => {
365 if (!menu.contains(ev.target)) {
366 close();
367 }
368 };
369 const onKey = (ev) => {
370 if (ev.key === "Escape") {
371 close();
372 }
373 };
374 menu.addEventListener("wpd-context-menu-pick", () => {
375 onToggle(!showHeart);
376 close();
377 });
378 document.body.appendChild(menu);
379 const rect = menu.getBoundingClientRect();
380 if (rect.right > window.innerWidth) {
381 menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 8)}px`;
382 }
383 if (rect.bottom > window.innerHeight) {
384 menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 8)}px`;
385 }
386 document.addEventListener("pointerdown", onOutside, true);
387 document.addEventListener("keydown", onKey, true);
388 }
389 function buildHalo(pixi) {
390 const g = new pixi.Graphics();
391 const radii = [HEART_SIZE * 1.2, HEART_SIZE * 1.05, HEART_SIZE * 0.85];
392 const alphas = [0.12, 0.18, 0.28];
393 radii.forEach((r, i) => {
394 g.circle(0, 0, r);
395 g.fill({ color: HEART_COLOR_BEAT, alpha: alphas[i] });
396 });
397 g.alpha = 0.1;
398 return g;
399 }
400 function heartPath(scaleMul = 1) {
401 const samples = 240;
402 const pts = [];
403 const s = HEART_SIZE / 17 * scaleMul;
404 for (let i = 0; i <= samples; i++) {
405 const t = i / samples * Math.PI * 2;
406 const x = 16 * 1.08 * Math.sin(t) ** 3;
407 const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
408 pts.push(x * s, y * s);
409 }
410 return pts;
411 }
412 function buildHeart(pixi) {
413 const wrap = new pixi.Container();
414 const bounds = heartBoundingY();
415 const shadow = new pixi.Graphics();
416 shadow.poly(heartPath(1.05));
417 shadow.fill({ color: 0, alpha: 0.55 });
418 shadow.y = 5;
419 shadow.alpha = 0.55;
420 wrap.addChild(shadow);
421 const gradientCanvas = makeGradientCanvas();
422 const gradientTexture = pixi.Texture.from(gradientCanvas);
423 const gradientSprite = new pixi.Sprite(gradientTexture);
424 const heartHeight = bounds.maxY - bounds.minY;
425 const overscan = HEART_SIZE * 2.5;
426 gradientSprite.width = overscan;
427 gradientSprite.height = heartHeight;
428 gradientSprite.x = -overscan / 2;
429 gradientSprite.y = bounds.minY;
430 gradientSprite.tint = HEART_COLOR_REST;
431 const mask = new pixi.Graphics();
432 mask.poly(heartPath(1));
433 mask.fill({ color: 16777215, alpha: 1 });
434 gradientSprite.mask = mask;
435 wrap.addChild(mask);
436 wrap.addChild(gradientSprite);
437 const hi1 = new pixi.Graphics();
438 hi1.ellipse(
439 -HEART_SIZE * 0.32,
440 -HEART_SIZE * 0.5,
441 HEART_SIZE * 0.18,
442 HEART_SIZE * 0.1
443 );
444 hi1.fill({ color: 16777215, alpha: 0.45 });
445 hi1.rotation = -0.5;
446 wrap.addChild(hi1);
447 const hi2 = new pixi.Graphics();
448 hi2.ellipse(
449 HEART_SIZE * 0.2,
450 -HEART_SIZE * 0.38,
451 HEART_SIZE * 0.09,
452 HEART_SIZE * 0.05
453 );
454 hi2.fill({ color: 16777215, alpha: 0.22 });
455 hi2.rotation = 0.4;
456 wrap.addChild(hi2);
457 const outline = new pixi.Graphics();
458 outline.poly(heartPath(1));
459 outline.stroke({ color: 16777215, alpha: 0.18, width: 1 });
460 wrap.addChild(outline);
461 return { view: wrap, body: gradientSprite };
462 }
463 function heartBoundingY() {
464 const pts = heartPath(1);
465 let minY = Infinity;
466 let maxY = -Infinity;
467 for (let i = 1; i < pts.length; i += 2) {
468 if (pts[i] < minY) {
469 minY = pts[i];
470 }
471 if (pts[i] > maxY) {
472 maxY = pts[i];
473 }
474 }
475 return { minY, maxY };
476 }
477 function makeGradientCanvas() {
478 const c = document.createElement("canvas");
479 c.width = 2;
480 c.height = 512;
481 const ctx = c.getContext("2d");
482 if (!ctx) {
483 return c;
484 }
485 const grad = ctx.createLinearGradient(0, 0, 0, 512);
486 grad.addColorStop(0, "#ffd6e3");
487 grad.addColorStop(0.18, "#ff8ba3");
488 grad.addColorStop(0.42, "#ff4d6d");
489 grad.addColorStop(0.72, "#9a1f3d");
490 grad.addColorStop(1, "#3d061a");
491 ctx.fillStyle = grad;
492 ctx.fillRect(0, 0, 2, 512);
493 return c;
494 }
495 function buildLogoSprite(pixi, url) {
496 const wrap = new pixi.Container();
497 wrap.y = HEART_SIZE * 0.08;
498 const shadow = new pixi.Sprite();
499 shadow.anchor.set(0.5);
500 shadow.tint = HEART_PALETTE.rim;
501 shadow.alpha = 0.6;
502 shadow.y = HEART_SIZE * 0.035;
503 wrap.addChild(shadow);
504 const mark = new pixi.Sprite();
505 mark.anchor.set(0.5);
506 wrap.addChild(mark);
507 const targetWidth = HEART_SIZE * 0.92;
508 const img = new Image();
509 img.src = url;
510 img.decode().then(() => {
511 const resolution = Math.min(window.devicePixelRatio || 1, 2);
512 const widthPx = Math.ceil(targetWidth * resolution * 1.7);
513 const canvas = rasterizeLogo(img, widthPx);
514 const texture = pixi.Texture.from(canvas);
515 const scale = targetWidth / canvas.width;
516 shadow.texture = texture;
517 shadow.scale.set(scale);
518 mark.texture = texture;
519 mark.scale.set(scale);
520 }).catch(() => {
521 });
522 return wrap;
523 }
524 function rasterizeLogo(img, widthPx) {
525 let src = img;
526 let w2 = img.naturalWidth;
527 let h = img.naturalHeight;
528 while (w2 / 2 >= widthPx * 2) {
529 w2 = Math.round(w2 / 2);
530 h = Math.round(h / 2);
531 const step = document.createElement("canvas");
532 step.width = w2;
533 step.height = h;
534 const g2 = step.getContext("2d");
535 if (!g2) {
536 break;
537 }
538 g2.imageSmoothingEnabled = true;
539 g2.imageSmoothingQuality = "high";
540 g2.drawImage(src, 0, 0, w2, h);
541 src = step;
542 }
543 const out = document.createElement("canvas");
544 out.width = Math.max(1, widthPx);
545 out.height = Math.max(
546 1,
547 Math.round(widthPx * img.naturalHeight / Math.max(1, img.naturalWidth))
548 );
549 const g = out.getContext("2d");
550 if (g) {
551 g.imageSmoothingEnabled = true;
552 g.imageSmoothingQuality = "high";
553 g.drawImage(src, 0, 0, out.width, out.height);
554 }
555 return out;
556 }
557 function wpHeartbeatInterval() {
558 const wp = window.wp;
559 try {
560 const fn = wp?.heartbeat?.interval;
561 if (typeof fn === "function") {
562 const v = Number(fn());
563 if (Number.isFinite(v) && v > 0) {
564 return v;
565 }
566 }
567 } catch (e) {
568 }
569 return 15;
570 }
571 function lerpColor(a, b, t) {
572 const ar = Math.trunc(a / 65536) % 256;
573 const ag = Math.trunc(a / 256) % 256;
574 const ab = a % 256;
575 const br = Math.trunc(b / 65536) % 256;
576 const bg = Math.trunc(b / 256) % 256;
577 const bb = b % 256;
578 const r = Math.round(ar + (br - ar) * t);
579 const g = Math.round(ag + (bg - ag) * t);
580 const bv = Math.round(ab + (bb - ab) * t);
581 return r * 65536 + g * 256 + bv;
582 }
583 function clamp(v, lo, hi) {
584 if (v < lo) {
585 return lo;
586 }
587 if (v > hi) {
588 return hi;
589 }
590 return v;
591 }
592 const w = window;
593 w.desktopModeWidgets = w.desktopModeWidgets || {};
594 w.desktopModeWidgets["desktop-mode/heartbeat"] = mount;
595 })();
596