| 1 |
(function() { |
| 2 |
"use strict"; |
| 3 |
const TEXT_DOMAIN = "desktop-mode"; |
| 4 |
function i18n() { |
| 5 |
return window.wp?.i18n; |
| 6 |
} |
| 7 |
function __(text, domain = TEXT_DOMAIN) { |
| 8 |
return i18n()?.__(text, domain) ?? text; |
| 9 |
} |
| 10 |
function getWpHooks() { |
| 11 |
const hooks = window.wp?.hooks; |
| 12 |
if (!hooks) { |
| 13 |
throw new Error( |
| 14 |
"[desktop-mode] `window.wp.hooks` is not available. The plugin declares `wp-hooks` as a script dependency; if you are seeing this error, verify the enqueue order." |
| 15 |
); |
| 16 |
} |
| 17 |
return hooks; |
| 18 |
} |
| 19 |
function addAction(hookName, namespace, callback, priority) { |
| 20 |
getWpHooks().addAction( |
| 21 |
hookName, |
| 22 |
namespace, |
| 23 |
callback, |
| 24 |
priority |
| 25 |
); |
| 26 |
} |
| 27 |
function removeAction(hookName, namespace) { |
| 28 |
return getWpHooks().removeAction(hookName, namespace); |
| 29 |
} |
| 30 |
const HOOKS = { |
| 31 |
/** Action mirroring document.visibilitychange for active canvas wallpapers. */ |
| 32 |
WALLPAPER_VISIBILITY: "desktop-mode.wallpaper.visibility", |
| 33 |
/** |
| 34 |
* Action, fires after a wallpaper's persisted settings change (the |
| 35 |
* user edited them through the wallpaper's config dialog in OS |
| 36 |
* Settings). Payload: `{ id, settings }` — the wallpaper id and the |
| 37 |
* full post-merge settings object. A mounted wallpaper subscribes to |
| 38 |
* live-apply changes without a remount. |
| 39 |
* |
| 40 |
* @since 0.9.5 |
| 41 |
*/ |
| 42 |
WALLPAPER_SETTINGS_CHANGED: "desktop-mode.wallpaper.settings-changed", |
| 43 |
/** |
| 44 |
* Action, fires BEFORE the window's element is detached from the |
| 45 |
* DOM but AFTER the manager has already removed it from the stack. |
| 46 |
* Payload: `{ windowId: string, element: HTMLElement }`. |
| 47 |
* |
| 48 |
* Use this for cleanup that needs a reference to the live |
| 49 |
* element (removing anchored snow, wallpaper particles pinned to |
| 50 |
* window tops, measurement caches keyed by element). `WINDOW_CLOSED` |
| 51 |
* fires immediately after and only carries the id, which means |
| 52 |
* subscribers would otherwise have to re-query the DOM — by then |
| 53 |
* the element is gone, so they can't match at all. |
| 54 |
*/ |
| 55 |
WINDOW_CLOSING: "desktop-mode.window.closing", |
| 56 |
/** |
| 57 |
* Action, fires when a window is minimized. Payload: |
| 58 |
* `{ windowId: string, element: HTMLElement }`. |
| 59 |
* |
| 60 |
* The element ride-along matches {@link WINDOW_CLOSING}'s shape so |
| 61 |
* wallpaper plugins anchored to window tops (snow, leaves, rain |
| 62 |
* splash) can match stuck particles by element identity and run |
| 63 |
* their teardown — minimized windows render at `opacity: 0` so |
| 64 |
* `offsetParent === null` checks miss them. |
| 65 |
*/ |
| 66 |
WINDOW_MINIMIZED: "desktop-mode.window.minimized", |
| 67 |
/** |
| 68 |
* Action, fires when a window is restored from minimized. Payload: |
| 69 |
* `{ windowId: string, element: HTMLElement }`. |
| 70 |
*/ |
| 71 |
WINDOW_RESTORED: "desktop-mode.window.restored", |
| 72 |
/** |
| 73 |
* Action, fires when a window is maximized (fills desktop area). |
| 74 |
* Payload: `{ windowId: string, element: HTMLElement }`. |
| 75 |
*/ |
| 76 |
WINDOW_MAXIMIZED: "desktop-mode.window.maximized", |
| 77 |
/** |
| 78 |
* Action, fires when a window exits maximized state. Payload: |
| 79 |
* `{ windowId: string, element: HTMLElement }`. |
| 80 |
*/ |
| 81 |
WINDOW_UNMAXIMIZED: "desktop-mode.window.unmaximized", |
| 82 |
/** |
| 83 |
* Action, fires when a window enters fullscreen / focus mode. |
| 84 |
* Payload: `{ windowId: string, element: HTMLElement }`. |
| 85 |
*/ |
| 86 |
WINDOW_FULLSCREEN_ENTERED: "desktop-mode.window.fullscreen-entered", |
| 87 |
/** |
| 88 |
* Action, fires when a window exits fullscreen / focus mode. |
| 89 |
* Payload: `{ windowId: string, element: HTMLElement }`. |
| 90 |
*/ |
| 91 |
WINDOW_FULLSCREEN_EXITED: "desktop-mode.window.fullscreen-exited", |
| 92 |
/** |
| 93 |
* Action, fires at most once per animation frame during an |
| 94 |
* active drag or resize with the live geometry. Payload: `{ |
| 95 |
* windowId: string, x: number, y: number, width: number, |
| 96 |
* height: number, state: WindowState, phase: 'drag' | 'resize' }`. |
| 97 |
* |
| 98 |
* Intended for per-frame collision-aware wallpapers (snow piling |
| 99 |
* on window tops, rain splash on edges) that would otherwise |
| 100 |
* poll `getBoundingClientRect` every rAF. Coalesced via |
| 101 |
* `requestAnimationFrame` so a pointermove storm collapses to |
| 102 |
* one fire per paint — matches the cadence a wallpaper's own |
| 103 |
* ticker runs at. |
| 104 |
* |
| 105 |
* NOT fired at drag/resize end — `WINDOW_DRAG_END` / |
| 106 |
* `WINDOW_RESIZE_END` handle the settled geometry. Subscribers |
| 107 |
* that only want the final position should listen to those |
| 108 |
* instead. |
| 109 |
*/ |
| 110 |
WINDOW_BOUNDS_CHANGED: "desktop-mode.window.bounds-changed", |
| 111 |
/** Action before a widget tears down. Payload `{ id }`. */ |
| 112 |
WIDGET_UNMOUNTING: "desktop-mode.widget.unmounting" |
| 113 |
}; |
| 114 |
function html(strings, ...values) { |
| 115 |
return { __wpdHtml: true, strings, values }; |
| 116 |
} |
| 117 |
function isTemplateResult(v) { |
| 118 |
return !!v && v.__wpdHtml === true; |
| 119 |
} |
| 120 |
const MARKER_PREFIX = "$$wpd$$"; |
| 121 |
const MARKER_RE = /\$\$wpd\$\$(\d+)\$\$/g; |
| 122 |
function joinWithMarkers(strings) { |
| 123 |
let out = strings[0]; |
| 124 |
for (let i = 1; i < strings.length; i++) { |
| 125 |
out += `${MARKER_PREFIX}${i - 1}$$` + strings[i]; |
| 126 |
} |
| 127 |
return out; |
| 128 |
} |
| 129 |
const compiledCache = /* @__PURE__ */ new WeakMap(); |
| 130 |
function compile(strings) { |
| 131 |
const cached = compiledCache.get(strings); |
| 132 |
if (cached) { |
| 133 |
return cached; |
| 134 |
} |
| 135 |
const template = document.createElement("template"); |
| 136 |
template.innerHTML = joinWithMarkers(strings); |
| 137 |
const recipes = []; |
| 138 |
const walk = (node, path) => { |
| 139 |
if (node.nodeType === Node.ELEMENT_NODE) { |
| 140 |
const el = node; |
| 141 |
for (const attr of Array.from(el.attributes)) { |
| 142 |
const rawName = attr.name; |
| 143 |
const rawValue = attr.value; |
| 144 |
const prefix = rawName[0]; |
| 145 |
if (MARKER_RE.test(rawValue)) { |
| 146 |
MARKER_RE.lastIndex = 0; |
| 147 |
if (prefix === "@") { |
| 148 |
const match = MARKER_RE.exec(rawValue); |
| 149 |
MARKER_RE.lastIndex = 0; |
| 150 |
recipes.push({ |
| 151 |
path, |
| 152 |
kind: "event", |
| 153 |
name: rawName.slice(1), |
| 154 |
valueIndex: match ? Number(match[1]) : 0 |
| 155 |
}); |
| 156 |
el.removeAttribute(rawName); |
| 157 |
} else if (prefix === ".") { |
| 158 |
const match = MARKER_RE.exec(rawValue); |
| 159 |
MARKER_RE.lastIndex = 0; |
| 160 |
recipes.push({ |
| 161 |
path, |
| 162 |
kind: "prop", |
| 163 |
name: rawName.slice(1), |
| 164 |
valueIndex: match ? Number(match[1]) : 0 |
| 165 |
}); |
| 166 |
el.removeAttribute(rawName); |
| 167 |
} else if (prefix === "?") { |
| 168 |
const match = MARKER_RE.exec(rawValue); |
| 169 |
MARKER_RE.lastIndex = 0; |
| 170 |
recipes.push({ |
| 171 |
path, |
| 172 |
kind: "bool", |
| 173 |
name: rawName.slice(1), |
| 174 |
valueIndex: match ? Number(match[1]) : 0 |
| 175 |
}); |
| 176 |
el.removeAttribute(rawName); |
| 177 |
} else { |
| 178 |
const fragments = []; |
| 179 |
const indices = []; |
| 180 |
let lastEnd = 0; |
| 181 |
let m; |
| 182 |
MARKER_RE.lastIndex = 0; |
| 183 |
while ((m = MARKER_RE.exec(rawValue)) !== null) { |
| 184 |
fragments.push(rawValue.slice(lastEnd, m.index)); |
| 185 |
indices.push(Number(m[1])); |
| 186 |
lastEnd = m.index + m[0].length; |
| 187 |
} |
| 188 |
fragments.push(rawValue.slice(lastEnd)); |
| 189 |
recipes.push({ |
| 190 |
path, |
| 191 |
kind: "attr", |
| 192 |
name: rawName, |
| 193 |
template: fragments, |
| 194 |
valueIndices: indices |
| 195 |
}); |
| 196 |
el.setAttribute(rawName, ""); |
| 197 |
} |
| 198 |
} |
| 199 |
} |
| 200 |
} |
| 201 |
const children = Array.from(node.childNodes); |
| 202 |
let shift = 0; |
| 203 |
for (let i = 0; i < children.length; i++) { |
| 204 |
const child = children[i]; |
| 205 |
const liveIndex = i + shift; |
| 206 |
if (child.nodeType === Node.TEXT_NODE) { |
| 207 |
const text = child.textContent || ""; |
| 208 |
if (!MARKER_RE.test(text)) { |
| 209 |
MARKER_RE.lastIndex = 0; |
| 210 |
continue; |
| 211 |
} |
| 212 |
MARKER_RE.lastIndex = 0; |
| 213 |
const parent = child.parentNode; |
| 214 |
let lastEnd = 0; |
| 215 |
let m; |
| 216 |
const newNodes = []; |
| 217 |
const newRecipes = []; |
| 218 |
MARKER_RE.lastIndex = 0; |
| 219 |
while ((m = MARKER_RE.exec(text)) !== null) { |
| 220 |
if (m.index > lastEnd) { |
| 221 |
newNodes.push(document.createTextNode(text.slice(lastEnd, m.index))); |
| 222 |
} |
| 223 |
const placeholder = document.createTextNode(""); |
| 224 |
newNodes.push(placeholder); |
| 225 |
newRecipes.push({ |
| 226 |
path: [...path, liveIndex + newNodes.length - 1], |
| 227 |
kind: "node", |
| 228 |
valueIndex: Number(m[1]) |
| 229 |
}); |
| 230 |
lastEnd = m.index + m[0].length; |
| 231 |
} |
| 232 |
if (lastEnd < text.length) { |
| 233 |
newNodes.push(document.createTextNode(text.slice(lastEnd))); |
| 234 |
} |
| 235 |
for (const nn of newNodes) { |
| 236 |
parent.insertBefore(nn, child); |
| 237 |
} |
| 238 |
parent.removeChild(child); |
| 239 |
shift += newNodes.length - 1; |
| 240 |
recipes.push(...newRecipes); |
| 241 |
} else { |
| 242 |
walk(child, [...path, liveIndex]); |
| 243 |
} |
| 244 |
} |
| 245 |
}; |
| 246 |
walk(template.content, []); |
| 247 |
const buildParts = (fragment) => { |
| 248 |
const out = []; |
| 249 |
for (const r of recipes) { |
| 250 |
let node = fragment; |
| 251 |
for (const idx of r.path) { |
| 252 |
node = node.childNodes[idx]; |
| 253 |
} |
| 254 |
if (r.kind === "node") { |
| 255 |
out.push({ |
| 256 |
kind: "node", |
| 257 |
valueIndex: r.valueIndex, |
| 258 |
child: { |
| 259 |
anchor: node, |
| 260 |
state: null |
| 261 |
} |
| 262 |
}); |
| 263 |
} else if (r.kind === "attr") { |
| 264 |
out.push({ |
| 265 |
kind: "attr", |
| 266 |
element: node, |
| 267 |
name: r.name, |
| 268 |
template: r.template, |
| 269 |
valueIndices: r.valueIndices |
| 270 |
}); |
| 271 |
} else if (r.kind === "event") { |
| 272 |
out.push({ |
| 273 |
kind: "event", |
| 274 |
valueIndex: r.valueIndex, |
| 275 |
element: node, |
| 276 |
name: r.name |
| 277 |
}); |
| 278 |
} else if (r.kind === "prop") { |
| 279 |
out.push({ |
| 280 |
kind: "prop", |
| 281 |
valueIndex: r.valueIndex, |
| 282 |
element: node, |
| 283 |
name: r.name |
| 284 |
}); |
| 285 |
} else if (r.kind === "bool") { |
| 286 |
out.push({ |
| 287 |
kind: "bool", |
| 288 |
valueIndex: r.valueIndex, |
| 289 |
element: node, |
| 290 |
name: r.name |
| 291 |
}); |
| 292 |
} |
| 293 |
} |
| 294 |
return out; |
| 295 |
}; |
| 296 |
const entry = { template, buildParts }; |
| 297 |
compiledCache.set(strings, entry); |
| 298 |
return entry; |
| 299 |
} |
| 300 |
const mountState = /* @__PURE__ */ new WeakMap(); |
| 301 |
function mountIntact(state, container) { |
| 302 |
for (const node of state.nodes) { |
| 303 |
if (node.parentNode !== container) { |
| 304 |
return false; |
| 305 |
} |
| 306 |
} |
| 307 |
return true; |
| 308 |
} |
| 309 |
function render(result, container) { |
| 310 |
const existing = mountState.get(container); |
| 311 |
if (existing && existing.strings === result.strings && mountIntact(existing, container)) { |
| 312 |
applyValues(existing.parts, result.values); |
| 313 |
return; |
| 314 |
} |
| 315 |
const compiled = compile(result.strings); |
| 316 |
const fragment = compiled.template.content.cloneNode(true); |
| 317 |
const parts = compiled.buildParts(fragment); |
| 318 |
const nodes = Array.from(fragment.childNodes); |
| 319 |
while (container.firstChild) { |
| 320 |
container.removeChild(container.firstChild); |
| 321 |
} |
| 322 |
container.appendChild(fragment); |
| 323 |
applyValues(parts, result.values); |
| 324 |
mountState.set(container, { strings: result.strings, parts, nodes }); |
| 325 |
} |
| 326 |
function applyValues(parts, values) { |
| 327 |
for (const part of parts) { |
| 328 |
if (part.kind === "node") { |
| 329 |
updateChildPart(part.child, values[part.valueIndex]); |
| 330 |
} else if (part.kind === "attr") { |
| 331 |
let composed = part.template[0]; |
| 332 |
for (let i = 0; i < part.valueIndices.length; i++) { |
| 333 |
composed += formatText(values[part.valueIndices[i]]); |
| 334 |
composed += part.template[i + 1]; |
| 335 |
} |
| 336 |
if (composed !== part.last) { |
| 337 |
part.last = composed; |
| 338 |
if (composed === "") { |
| 339 |
part.element.removeAttribute(part.name); |
| 340 |
} else { |
| 341 |
part.element.setAttribute(part.name, composed); |
| 342 |
} |
| 343 |
} |
| 344 |
} else if (part.kind === "event") { |
| 345 |
const next = values[part.valueIndex]; |
| 346 |
if (next !== part.current) { |
| 347 |
if (part.current) { |
| 348 |
part.element.removeEventListener(part.name, part.current); |
| 349 |
} |
| 350 |
if (next) { |
| 351 |
part.element.addEventListener(part.name, next); |
| 352 |
} |
| 353 |
part.current = next; |
| 354 |
} |
| 355 |
} else if (part.kind === "prop") { |
| 356 |
const next = values[part.valueIndex]; |
| 357 |
if (next !== part.last) { |
| 358 |
part.last = next; |
| 359 |
part.element[part.name] = next; |
| 360 |
} |
| 361 |
} else if (part.kind === "bool") { |
| 362 |
const next = !!values[part.valueIndex]; |
| 363 |
if (next !== part.last) { |
| 364 |
part.last = next; |
| 365 |
if (next) { |
| 366 |
part.element.setAttribute(part.name, ""); |
| 367 |
} else { |
| 368 |
part.element.removeAttribute(part.name); |
| 369 |
} |
| 370 |
} |
| 371 |
} |
| 372 |
} |
| 373 |
} |
| 374 |
function updateChildPart(child, value) { |
| 375 |
if (value === null || value === void 0 || value === false) { |
| 376 |
if (child.state) { |
| 377 |
disposeChildState(child.state); |
| 378 |
child.state = null; |
| 379 |
} |
| 380 |
return; |
| 381 |
} |
| 382 |
if (Array.isArray(value)) { |
| 383 |
updateArrayChild(child, value); |
| 384 |
return; |
| 385 |
} |
| 386 |
if (isTemplateResult(value)) { |
| 387 |
updateTemplateChild(child, value); |
| 388 |
return; |
| 389 |
} |
| 390 |
if (value instanceof Node) { |
| 391 |
updateNodeChild(child, value); |
| 392 |
return; |
| 393 |
} |
| 394 |
updateTextChild(child, formatText(value)); |
| 395 |
} |
| 396 |
function updateNodeChild(child, node) { |
| 397 |
const old = child.state; |
| 398 |
if (old?.shape === "node" && old.node === node) { |
| 399 |
return; |
| 400 |
} |
| 401 |
if (old) { |
| 402 |
disposeChildState(old); |
| 403 |
} |
| 404 |
insertBeforeAnchor(child, [node]); |
| 405 |
child.state = { shape: "node", node }; |
| 406 |
} |
| 407 |
function updateTextChild(child, text) { |
| 408 |
const old = child.state; |
| 409 |
if (old?.shape === "text") { |
| 410 |
if (old.text !== text) { |
| 411 |
old.node.textContent = text; |
| 412 |
old.text = text; |
| 413 |
} |
| 414 |
return; |
| 415 |
} |
| 416 |
if (old) { |
| 417 |
disposeChildState(old); |
| 418 |
} |
| 419 |
const node = document.createTextNode(text); |
| 420 |
insertBeforeAnchor(child, [node]); |
| 421 |
child.state = { shape: "text", node, text }; |
| 422 |
} |
| 423 |
function updateTemplateChild(child, result) { |
| 424 |
const old = child.state; |
| 425 |
if (old?.shape === "template" && old.strings === result.strings) { |
| 426 |
applyValues(old.parts, result.values); |
| 427 |
return; |
| 428 |
} |
| 429 |
if (old) { |
| 430 |
disposeChildState(old); |
| 431 |
} |
| 432 |
const compiled = compile(result.strings); |
| 433 |
const fragment = compiled.template.content.cloneNode(true); |
| 434 |
const parts = compiled.buildParts(fragment); |
| 435 |
const topNodes = Array.from(fragment.childNodes); |
| 436 |
insertBeforeAnchor(child, [fragment]); |
| 437 |
applyValues(parts, result.values); |
| 438 |
child.state = { |
| 439 |
shape: "template", |
| 440 |
strings: result.strings, |
| 441 |
parts, |
| 442 |
nodes: topNodes |
| 443 |
}; |
| 444 |
} |
| 445 |
function updateArrayChild(child, arr) { |
| 446 |
const old = child.state; |
| 447 |
if (old?.shape === "array" && old.entries.length === arr.length) { |
| 448 |
for (let i = 0; i < arr.length; i++) { |
| 449 |
updateChildPart(old.entries[i], arr[i]); |
| 450 |
} |
| 451 |
return; |
| 452 |
} |
| 453 |
if (old) { |
| 454 |
disposeChildState(old); |
| 455 |
} |
| 456 |
const entries = []; |
| 457 |
for (const v of arr) { |
| 458 |
const entryAnchor = document.createTextNode(""); |
| 459 |
insertBeforeAnchor(child, [entryAnchor]); |
| 460 |
const entry = { anchor: entryAnchor, state: null }; |
| 461 |
updateChildPart(entry, v); |
| 462 |
entries.push(entry); |
| 463 |
} |
| 464 |
child.state = { shape: "array", entries }; |
| 465 |
} |
| 466 |
function insertBeforeAnchor(child, nodes) { |
| 467 |
const parent = child.anchor.parentNode; |
| 468 |
if (!parent) { |
| 469 |
return; |
| 470 |
} |
| 471 |
for (const node of nodes) { |
| 472 |
parent.insertBefore(node, child.anchor); |
| 473 |
} |
| 474 |
} |
| 475 |
function disposeChildState(state) { |
| 476 |
if (state.shape === "text") { |
| 477 |
state.node.remove(); |
| 478 |
return; |
| 479 |
} |
| 480 |
if (state.shape === "template") { |
| 481 |
for (const node of state.nodes) { |
| 482 |
if (node.parentNode) { |
| 483 |
node.parentNode.removeChild(node); |
| 484 |
} |
| 485 |
} |
| 486 |
return; |
| 487 |
} |
| 488 |
if (state.shape === "node") { |
| 489 |
if (state.node.parentNode) { |
| 490 |
state.node.parentNode.removeChild(state.node); |
| 491 |
} |
| 492 |
return; |
| 493 |
} |
| 494 |
for (const entry of state.entries) { |
| 495 |
if (entry.state) { |
| 496 |
disposeChildState(entry.state); |
| 497 |
} |
| 498 |
entry.anchor.remove(); |
| 499 |
} |
| 500 |
} |
| 501 |
function formatText(v) { |
| 502 |
if (v === null || v === void 0 || v === false) { |
| 503 |
return ""; |
| 504 |
} |
| 505 |
return String(v); |
| 506 |
} |
| 507 |
const _Component = class _Component extends HTMLElement { |
| 508 |
constructor() { |
| 509 |
super(); |
| 510 |
this._renderScheduled = false; |
| 511 |
this._propValues = {}; |
| 512 |
const ctor = this.constructor; |
| 513 |
if (ctor.shadow) { |
| 514 |
this.attachShadow({ mode: "open" }); |
| 515 |
this._renderRoot = this.shadowRoot; |
| 516 |
} else { |
| 517 |
this._renderRoot = this; |
| 518 |
} |
| 519 |
this._installPropAccessors(); |
| 520 |
} |
| 521 |
static get observedAttributes() { |
| 522 |
return this.props.map(kebab); |
| 523 |
} |
| 524 |
connectedCallback() { |
| 525 |
this._adoptStyles(); |
| 526 |
this.requestUpdate(); |
| 527 |
} |
| 528 |
attributeChangedCallback(name, oldValue, newValue) { |
| 529 |
if (oldValue === newValue) { |
| 530 |
return; |
| 531 |
} |
| 532 |
const prop = camel(name); |
| 533 |
this._propValues[prop] = newValue; |
| 534 |
this.requestUpdate(); |
| 535 |
} |
| 536 |
/** |
| 537 |
* Declarative class-name setter. Assign an array (or a |
| 538 |
* space-separated string) and the host's `class` attribute is |
| 539 |
* rewritten to match. Intended for programmatic styling — when |
| 540 |
* a plugin has enqueued its own stylesheet and wants to apply |
| 541 |
* one of those classes to a shell component: |
| 542 |
* |
| 543 |
* ```js |
| 544 |
* element.classNames = [ 'my-plugin-brand', 'is-active' ]; |
| 545 |
* // → <wpd-select class="my-plugin-brand is-active"> |
| 546 |
* ``` |
| 547 |
* |
| 548 |
* The plain HTML `class="…"` attribute works just the same and |
| 549 |
* is always preferred when writing markup by hand — this setter |
| 550 |
* exists for the JS-API case where the caller has an array of |
| 551 |
* conditional classes in hand. |
| 552 |
* |
| 553 |
* Getter returns the current `classList` as a plain array for |
| 554 |
* symmetric read/write. |
| 555 |
* |
| 556 |
* @since 0.5.0 |
| 557 |
*/ |
| 558 |
get classNames() { |
| 559 |
return Array.from(this.classList); |
| 560 |
} |
| 561 |
set classNames(next) { |
| 562 |
if (next === null || next === void 0) { |
| 563 |
this.removeAttribute("class"); |
| 564 |
return; |
| 565 |
} |
| 566 |
const list = Array.isArray(next) ? next : String(next).split(/\s+/); |
| 567 |
const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== ""); |
| 568 |
this.className = cleaned.join(" "); |
| 569 |
} |
| 570 |
/** |
| 571 |
* Request a re-render explicitly. Components rarely need this — |
| 572 |
* declare state via props + attribute observers and the render |
| 573 |
* loop picks up changes automatically. |
| 574 |
*/ |
| 575 |
requestUpdate() { |
| 576 |
this._scheduleRender(); |
| 577 |
} |
| 578 |
/** |
| 579 |
* Dispatch a `CustomEvent` with a `detail`. Bubbles + composed |
| 580 |
* by default (matches typical WC UX — events cross shadow |
| 581 |
* boundaries, parents can listen without knowing about internal |
| 582 |
* structure). |
| 583 |
*/ |
| 584 |
emit(name, detail) { |
| 585 |
return this.dispatchEvent( |
| 586 |
new CustomEvent(name, { |
| 587 |
detail, |
| 588 |
bubbles: true, |
| 589 |
composed: true |
| 590 |
}) |
| 591 |
); |
| 592 |
} |
| 593 |
// ------------------------------------------------------------------ |
| 594 |
// Internals |
| 595 |
// ------------------------------------------------------------------ |
| 596 |
/** |
| 597 |
* Wire every `static props` entry to a matched property getter + |
| 598 |
* setter on the element. Setting the property reflects into the |
| 599 |
* attribute (so downstream observers + CSS selectors see it); |
| 600 |
* reading the property falls back to the attribute. |
| 601 |
*/ |
| 602 |
_installPropAccessors() { |
| 603 |
const ctor = this.constructor; |
| 604 |
for (const prop of ctor.props) { |
| 605 |
if (Object.getOwnPropertyDescriptor(this, prop)) { |
| 606 |
continue; |
| 607 |
} |
| 608 |
const attr = kebab(prop); |
| 609 |
Object.defineProperty(this, prop, { |
| 610 |
get: () => { |
| 611 |
if (prop in this._propValues) { |
| 612 |
return this._propValues[prop]; |
| 613 |
} |
| 614 |
return this.getAttribute(attr); |
| 615 |
}, |
| 616 |
set: (value) => { |
| 617 |
let str; |
| 618 |
if (value === null || value === void 0 || value === false) { |
| 619 |
str = null; |
| 620 |
} else if (value === true) { |
| 621 |
str = ""; |
| 622 |
} else { |
| 623 |
str = String(value); |
| 624 |
} |
| 625 |
this._propValues[prop] = str; |
| 626 |
if (str === null) { |
| 627 |
this.removeAttribute(attr); |
| 628 |
} else { |
| 629 |
this.setAttribute(attr, str); |
| 630 |
} |
| 631 |
this.requestUpdate(); |
| 632 |
}, |
| 633 |
enumerable: true, |
| 634 |
configurable: true |
| 635 |
}); |
| 636 |
} |
| 637 |
} |
| 638 |
/** |
| 639 |
* Schedule a render on the next microtask. Multiple property |
| 640 |
* assignments in the same tick collapse into a single render. |
| 641 |
*/ |
| 642 |
_scheduleRender() { |
| 643 |
if (this._renderScheduled || !this.isConnected) { |
| 644 |
return; |
| 645 |
} |
| 646 |
this._renderScheduled = true; |
| 647 |
queueMicrotask(() => { |
| 648 |
this._renderScheduled = false; |
| 649 |
if (!this.isConnected) { |
| 650 |
return; |
| 651 |
} |
| 652 |
render(this.render(), this._renderRoot); |
| 653 |
}); |
| 654 |
} |
| 655 |
/** |
| 656 |
* Mount adoptable stylesheets onto the shadow root (via |
| 657 |
* `adoptedStyleSheets`) or the light DOM (via one `<style>` |
| 658 |
* tag per def). No-op if `static styles` is empty. |
| 659 |
*/ |
| 660 |
_adoptStyles() { |
| 661 |
const ctor = this.constructor; |
| 662 |
if (ctor.styles.length === 0) { |
| 663 |
return; |
| 664 |
} |
| 665 |
if (ctor.shadow && this.shadowRoot) { |
| 666 |
const sheets = ctor.styles.map((s) => s.sheet).filter((s) => s !== null); |
| 667 |
this.shadowRoot.adoptedStyleSheets = sheets; |
| 668 |
if (sheets.length !== ctor.styles.length) { |
| 669 |
for (const s of ctor.styles) { |
| 670 |
if (!s.sheet) { |
| 671 |
const tag = document.createElement("style"); |
| 672 |
tag.textContent = s.cssText; |
| 673 |
this.shadowRoot.appendChild(tag); |
| 674 |
} |
| 675 |
} |
| 676 |
} |
| 677 |
} else { |
| 678 |
this._adoptLightStyles(ctor); |
| 679 |
} |
| 680 |
} |
| 681 |
_adoptLightStyles(ctor) { |
| 682 |
if (_Component._lightStylesAdopted.has(ctor)) { |
| 683 |
return; |
| 684 |
} |
| 685 |
_Component._lightStylesAdopted.add(ctor); |
| 686 |
for (const s of ctor.styles) { |
| 687 |
const tag = document.createElement("style"); |
| 688 |
tag.dataset.wpdUi = this.tagName.toLowerCase(); |
| 689 |
tag.textContent = s.cssText; |
| 690 |
document.head.appendChild(tag); |
| 691 |
} |
| 692 |
} |
| 693 |
}; |
| 694 |
_Component.props = []; |
| 695 |
_Component.styles = []; |
| 696 |
_Component.shadow = true; |
| 697 |
_Component._lightStylesAdopted = /* @__PURE__ */ new WeakSet(); |
| 698 |
let Component = _Component; |
| 699 |
function defineComponent(tag, ctor) { |
| 700 |
if (customElements.get(tag)) { |
| 701 |
return; |
| 702 |
} |
| 703 |
customElements.define(tag, ctor); |
| 704 |
} |
| 705 |
function kebab(s) { |
| 706 |
return s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); |
| 707 |
} |
| 708 |
function camel(s) { |
| 709 |
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); |
| 710 |
} |
| 711 |
const SUPPORTS_CONSTRUCTABLE_SHEETS = (() => { |
| 712 |
try { |
| 713 |
const s = new CSSStyleSheet(); |
| 714 |
return typeof s.replaceSync === "function"; |
| 715 |
} catch { |
| 716 |
return false; |
| 717 |
} |
| 718 |
})(); |
| 719 |
function css(strings, ...values) { |
| 720 |
let text = strings[0]; |
| 721 |
for (let i = 1; i < strings.length; i++) { |
| 722 |
const v = values[i - 1]; |
| 723 |
if (typeof v === "string" || typeof v === "number") { |
| 724 |
text += String(v); |
| 725 |
} else if (v && v.__wpdCss) { |
| 726 |
text += v.cssText; |
| 727 |
} else { |
| 728 |
throw new TypeError( |
| 729 |
"[wpd-ui] css`` interpolations must be strings, numbers, or other css`` results. Got: " + typeof v |
| 730 |
); |
| 731 |
} |
| 732 |
text += strings[i]; |
| 733 |
} |
| 734 |
if (SUPPORTS_CONSTRUCTABLE_SHEETS) { |
| 735 |
const sheet = new CSSStyleSheet(); |
| 736 |
sheet.replaceSync(text); |
| 737 |
return { __wpdCss: true, sheet, cssText: text }; |
| 738 |
} |
| 739 |
return { __wpdCss: true, sheet: null, cssText: text }; |
| 740 |
} |
| 741 |
const styles$2 = css`:host{display:flex;align-items:center;gap:10px;font-size:12px;color:var( --desktop-mode-muted,#646970 )}input[ type='range' ]{flex:1;accent-color:var( --wp-admin-theme-color,#2271b1 )}.wpd-range-field__value{min-width:3ch;text-align:end;font-variant-numeric:tabular-nums;color:var( --desktop-mode-text,#1d2327 )}`; |
| 742 |
const _WpdRangeField = class _WpdRangeField extends Component { |
| 743 |
render() { |
| 744 |
const label = this.label || ""; |
| 745 |
const value = this.value || "0"; |
| 746 |
const min = this.min || "0"; |
| 747 |
const max = this.max || "100"; |
| 748 |
const step = this.step || "1"; |
| 749 |
const suffix = this.suffix || ""; |
| 750 |
return html` |
| 751 |
<label class="wpd-range-field__label">${label}</label> |
| 752 |
<input |
| 753 |
type="range" |
| 754 |
min=${min} |
| 755 |
max=${max} |
| 756 |
step=${step} |
| 757 |
.value=${value} |
| 758 |
@input=${(e) => this._onInput(e)} |
| 759 |
/> |
| 760 |
<span class="wpd-range-field__value">${value}${suffix}</span> |
| 761 |
`; |
| 762 |
} |
| 763 |
_onInput(e) { |
| 764 |
const input = e.target; |
| 765 |
const n = parseFloat(input.value); |
| 766 |
if (!Number.isFinite(n)) { |
| 767 |
return; |
| 768 |
} |
| 769 |
this.value = String(n); |
| 770 |
this.emit("wpd-range-change", { value: n }); |
| 771 |
} |
| 772 |
}; |
| 773 |
_WpdRangeField.props = ["label", "value", "min", "max", "step", "suffix"]; |
| 774 |
_WpdRangeField.styles = [styles$2]; |
| 775 |
_WpdRangeField.help = { |
| 776 |
title: "Range field", |
| 777 |
summary: "Label + range slider + live numeric readout. Emits wpd-range-change with an already-parsed number.", |
| 778 |
status: "stable", |
| 779 |
since: "0.9.0", |
| 780 |
props: [ |
| 781 |
{ |
| 782 |
name: "label", |
| 783 |
type: "string", |
| 784 |
description: "Visible label above the slider." |
| 785 |
}, |
| 786 |
{ |
| 787 |
name: "value", |
| 788 |
type: "number (string)", |
| 789 |
default: "0", |
| 790 |
description: "Current slider value." |
| 791 |
}, |
| 792 |
{ |
| 793 |
name: "min", |
| 794 |
type: "number (string)", |
| 795 |
default: "0", |
| 796 |
description: "Lower bound of the slider range." |
| 797 |
}, |
| 798 |
{ |
| 799 |
name: "max", |
| 800 |
type: "number (string)", |
| 801 |
default: "100", |
| 802 |
description: "Upper bound of the slider range." |
| 803 |
}, |
| 804 |
{ |
| 805 |
name: "step", |
| 806 |
type: "number (string)", |
| 807 |
default: "1", |
| 808 |
description: "Slider step granularity." |
| 809 |
}, |
| 810 |
{ |
| 811 |
name: "suffix", |
| 812 |
type: "string", |
| 813 |
description: 'Text appended to the readout (e.g. "px", "%").' |
| 814 |
} |
| 815 |
], |
| 816 |
events: [ |
| 817 |
{ |
| 818 |
name: "wpd-range-change", |
| 819 |
description: "Fires on every slider movement.", |
| 820 |
detail: "{ value: number }" |
| 821 |
} |
| 822 |
], |
| 823 |
cssProps: [ |
| 824 |
{ name: "--desktop-mode-text", description: "Readout + label colour." }, |
| 825 |
{ name: "--desktop-mode-muted", description: "Secondary colour." } |
| 826 |
], |
| 827 |
example: html` |
| 828 |
<wpd-range-field |
| 829 |
label="Dock size" |
| 830 |
value="48" |
| 831 |
min="32" |
| 832 |
max="80" |
| 833 |
step="4" |
| 834 |
suffix="px" |
| 835 |
></wpd-range-field> |
| 836 |
` |
| 837 |
}; |
| 838 |
let WpdRangeField = _WpdRangeField; |
| 839 |
defineComponent("wpd-range-field", WpdRangeField); |
| 840 |
const styles$1 = css`:host{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var( --desktop-mode-muted,#646970 )}label{display:inline-flex;align-items:center;gap:8px}input[ type='color' ]{width:28px;height:28px;padding:0;border:1px solid var( --desktop-mode-border,#c3c4c7 );border-radius:6px;background:transparent;cursor:pointer}:host( [ variant='block' ] ){display:flex;width:100%}:host( [ variant='block' ] ) label{display:flex;flex:1;align-items:center}:host( [ variant='block' ] ) input[ type='color' ]{flex:1;width:auto;height:32px}input[ type='color' ]::-webkit-color-swatch-wrapper{padding:2px}input[ type='color' ]::-webkit-color-swatch{border:none;border-radius:2px}`; |
| 841 |
const _WpdColorField = class _WpdColorField extends Component { |
| 842 |
render() { |
| 843 |
const label = this.label || ""; |
| 844 |
const value = this.value || "#000000"; |
| 845 |
return html` |
| 846 |
<label> |
| 847 |
<span class="wpd-color-field__label">${label}</span> |
| 848 |
<input |
| 849 |
type="color" |
| 850 |
.value=${value} |
| 851 |
@input=${(e) => this._onInput(e)} |
| 852 |
/> |
| 853 |
</label> |
| 854 |
`; |
| 855 |
} |
| 856 |
_onInput(e) { |
| 857 |
const input = e.target; |
| 858 |
this.value = input.value; |
| 859 |
this.emit("wpd-color-change", { value: input.value }); |
| 860 |
} |
| 861 |
}; |
| 862 |
_WpdColorField.props = ["label", "value", "variant"]; |
| 863 |
_WpdColorField.styles = [styles$1]; |
| 864 |
_WpdColorField.help = { |
| 865 |
title: "Color field", |
| 866 |
summary: "Label + native color input. Reflects the value attribute both ways and emits wpd-color-change live on every edit (no debounce — callers debounce upstream).", |
| 867 |
status: "stable", |
| 868 |
since: "0.9.0", |
| 869 |
props: [ |
| 870 |
{ |
| 871 |
name: "label", |
| 872 |
type: "string", |
| 873 |
description: "Visible label rendered next to the swatch." |
| 874 |
}, |
| 875 |
{ |
| 876 |
name: "value", |
| 877 |
type: "CSS hex color", |
| 878 |
default: "#000000", |
| 879 |
description: "Current color. Two-way reflected with the native picker." |
| 880 |
}, |
| 881 |
{ |
| 882 |
name: "variant", |
| 883 |
type: "string", |
| 884 |
description: "Optional visual variant hint for the stylesheet." |
| 885 |
} |
| 886 |
], |
| 887 |
events: [ |
| 888 |
{ |
| 889 |
name: "wpd-color-change", |
| 890 |
description: "Fires on every user edit.", |
| 891 |
detail: "{ value: string }" |
| 892 |
} |
| 893 |
], |
| 894 |
cssProps: [ |
| 895 |
{ name: "--desktop-mode-border", description: "Swatch outline." }, |
| 896 |
{ name: "--desktop-mode-muted", description: "Label colour." } |
| 897 |
], |
| 898 |
example: html` |
| 899 |
<wpd-color-field label="Accent" value="#8b5cf6"></wpd-color-field> |
| 900 |
` |
| 901 |
}; |
| 902 |
let WpdColorField = _WpdColorField; |
| 903 |
defineComponent("wpd-color-field", WpdColorField); |
| 904 |
const styles = css`:host{display:inline-flex}:host( [ fill-cell ] ){display:flex;width:100%}button{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:var( --wpd-button-padding,6px 12px );border-radius:var( --wpd-button-border-radius,6px );font:inherit;font-weight:500;cursor:pointer;transition:background-color 0.12s ease,color 0.12s ease,border-color 0.12s ease;background:var( --wpd-button-bg,transparent );color:var( --wpd-button-fg,var( --desktop-mode-text,#1d2327 ) );border:var( --wpd-button-border,1px solid var( --desktop-mode-border,#c3c4c7 ) )}:host( [ fill-cell ] ) button{width:100%;min-height:var( --wpd-button-min-height,44px )}button:disabled{opacity:0.5;cursor:not-allowed}button:hover:not(:disabled ){background:var( --wpd-button-bg-hover,rgba( 0,0,0,0.04 ) )}:host( [ variant='primary' ] ) button{background:var( --wpd-button-bg,var( --wp-admin-theme-color,#2271b1 ) );color:var( --wpd-button-fg,#fff );border:var( --wpd-button-border,1px solid transparent )}:host( [ variant='primary' ] ) button:hover:not(:disabled ){filter:brightness( 1.06 );background:var( --wpd-button-bg,var( --wp-admin-theme-color,#2271b1 ) )}:host( [ variant='secondary' ] ) button{background:var( --wpd-button-bg,rgba( 0,0,0,0.06 ) );color:var( --wpd-button-fg,var( --desktop-mode-text,#1d2327 ) );border:var( --wpd-button-border,1px solid transparent )}:host( [ variant='secondary' ] ) button:hover:not(:disabled ){background:var( --wpd-button-bg-hover,rgba( 0,0,0,0.1 ) )}:host( [ variant='danger' ] ) button{background:var( --wpd-button-bg,transparent );color:var( --wpd-button-fg,#d63638 );border:var( --wpd-button-border,1px solid currentColor )}:host( [ variant='danger' ] ) button:hover:not(:disabled ){background:#d63638;color:#fff}:host( [ variant='link' ] ) button{background:transparent;color:var( --wpd-button-fg,var( --wp-admin-theme-color,#2271b1 ) );border:0;padding:0;text-decoration:underline}:host( [ busy ] ) button{pointer-events:none;opacity:0.75}.wpd-button__spinner{box-sizing:border-box;display:inline-block;width:12px;height:12px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:wpd-button-spin 0.6s linear infinite;flex-shrink:0}@keyframes wpd-button-spin{to{transform:rotate( 360deg )}}`; |
| 905 |
const _WpdButton = class _WpdButton extends Component { |
| 906 |
render() { |
| 907 |
const disabled = this.disabled !== null; |
| 908 |
const busy = this.busy !== null; |
| 909 |
const type = this.type || "button"; |
| 910 |
return html` |
| 911 |
<button |
| 912 |
part="button" |
| 913 |
type=${type} |
| 914 |
?disabled=${disabled || busy} |
| 915 |
aria-busy=${busy ? "true" : "false"} |
| 916 |
> |
| 917 |
${busy ? html`<span class="wpd-button__spinner" aria-hidden="true"></span>` : ""} |
| 918 |
<slot></slot> |
| 919 |
</button> |
| 920 |
`; |
| 921 |
} |
| 922 |
}; |
| 923 |
_WpdButton.props = ["variant", "disabled", "type", "busy", "fill-cell"]; |
| 924 |
_WpdButton.styles = [styles]; |
| 925 |
_WpdButton.help = { |
| 926 |
title: "Button", |
| 927 |
summary: "Thin wrapper around <button> with consistent variant styling and a slot for the label.", |
| 928 |
status: "stable", |
| 929 |
since: "0.9.0", |
| 930 |
props: [ |
| 931 |
{ |
| 932 |
name: "variant", |
| 933 |
type: "'primary' | 'secondary' | 'ghost' | 'danger' | 'link'", |
| 934 |
default: "ghost", |
| 935 |
description: "Visual weight of the button. Use primary for the single attention-grabbing action per surface." |
| 936 |
}, |
| 937 |
{ |
| 938 |
name: "disabled", |
| 939 |
type: "boolean attribute", |
| 940 |
description: "Disable pointer + keyboard interaction and dim the chrome." |
| 941 |
}, |
| 942 |
{ |
| 943 |
name: "type", |
| 944 |
type: "'button' | 'submit' | 'reset'", |
| 945 |
default: "button", |
| 946 |
description: "Forwarded to the underlying native <button>." |
| 947 |
}, |
| 948 |
{ |
| 949 |
name: "busy", |
| 950 |
type: "boolean attribute", |
| 951 |
description: "Marks the button as in-progress (e.g., awaiting a fetch)." |
| 952 |
}, |
| 953 |
{ |
| 954 |
name: "fill-cell", |
| 955 |
type: "boolean attribute", |
| 956 |
description: "Grow to fill the parent flex/grid cell. Useful for tiled keypads." |
| 957 |
} |
| 958 |
], |
| 959 |
slots: [{ name: "(default)", description: "Button label." }], |
| 960 |
parts: [{ name: "button", description: "Underlying <button> element." }], |
| 961 |
cssProps: [ |
| 962 |
{ name: "--wpd-button-bg", description: "Background color." }, |
| 963 |
{ |
| 964 |
name: "--wpd-button-bg-hover", |
| 965 |
description: "Hover wash (ghost + secondary variants)." |
| 966 |
}, |
| 967 |
{ name: "--wpd-button-fg", description: "Text color." }, |
| 968 |
{ name: "--wpd-button-border", description: "Border shorthand." }, |
| 969 |
{ name: "--wpd-button-border-radius", default: "6px" }, |
| 970 |
{ name: "--wpd-button-padding", default: "6px 12px" }, |
| 971 |
{ |
| 972 |
name: "--wpd-button-min-height", |
| 973 |
description: "Minimum height when fill-cell is set." |
| 974 |
} |
| 975 |
], |
| 976 |
example: html` |
| 977 |
<wpd-cluster gap="8"> |
| 978 |
<wpd-button variant="primary">Primary</wpd-button> |
| 979 |
<wpd-button variant="secondary">Secondary</wpd-button> |
| 980 |
<wpd-button variant="ghost">Ghost</wpd-button> |
| 981 |
<wpd-button variant="danger">Danger</wpd-button> |
| 982 |
<wpd-button variant="link">Link</wpd-button> |
| 983 |
</wpd-cluster> |
| 984 |
` |
| 985 |
}; |
| 986 |
let WpdButton = _WpdButton; |
| 987 |
defineComponent("wpd-button", WpdButton); |
| 988 |
function getPixi() { |
| 989 |
const pixi = window.PIXI; |
| 990 |
return pixi ?? null; |
| 991 |
} |
| 992 |
const SNOW_DEFAULTS = { |
| 993 |
wind: 22, |
| 994 |
particleCount: 660, |
| 995 |
flakeSize: 16, |
| 996 |
background: "#0c1a36" |
| 997 |
}; |
| 998 |
const SNOW_LIMITS = { |
| 999 |
wind: { min: 0, max: 80 }, |
| 1000 |
particleCount: { min: 100, max: 2e3 }, |
| 1001 |
flakeSize: { min: 6, max: 40 } |
| 1002 |
}; |
| 1003 |
function clampNumber(value, limits, fallback) { |
| 1004 |
if (typeof value !== "number" || !Number.isFinite(value)) { |
| 1005 |
return fallback; |
| 1006 |
} |
| 1007 |
return Math.min(limits.max, Math.max(limits.min, value)); |
| 1008 |
} |
| 1009 |
function sanitizeSnowSettings(raw) { |
| 1010 |
const bag = raw ?? {}; |
| 1011 |
return { |
| 1012 |
wind: clampNumber(bag.wind, SNOW_LIMITS.wind, SNOW_DEFAULTS.wind), |
| 1013 |
particleCount: Math.round( |
| 1014 |
clampNumber( |
| 1015 |
bag.particleCount, |
| 1016 |
SNOW_LIMITS.particleCount, |
| 1017 |
SNOW_DEFAULTS.particleCount |
| 1018 |
) |
| 1019 |
), |
| 1020 |
flakeSize: clampNumber( |
| 1021 |
bag.flakeSize, |
| 1022 |
SNOW_LIMITS.flakeSize, |
| 1023 |
SNOW_DEFAULTS.flakeSize |
| 1024 |
), |
| 1025 |
background: typeof bag.background === "string" && /^#[0-9a-f]{6}$/i.test(bag.background) ? bag.background.toLowerCase() : SNOW_DEFAULTS.background |
| 1026 |
}; |
| 1027 |
} |
| 1028 |
const STOP_55_DELTA = { h: -2.1538461538461604, s: -0.10790835181079084, l: 0.11176470588235296 }; |
| 1029 |
const STOP_100_DELTA = { h: -2.5, s: -0.2834224598930483, l: 0.2705882352941177 }; |
| 1030 |
function hexToHsl(hex) { |
| 1031 |
const r = parseInt(hex.slice(1, 3), 16) / 255; |
| 1032 |
const g = parseInt(hex.slice(3, 5), 16) / 255; |
| 1033 |
const b = parseInt(hex.slice(5, 7), 16) / 255; |
| 1034 |
const max = Math.max(r, g, b); |
| 1035 |
const min = Math.min(r, g, b); |
| 1036 |
const l = (max + min) / 2; |
| 1037 |
const d = max - min; |
| 1038 |
if (d === 0) { |
| 1039 |
return { h: 0, s: 0, l }; |
| 1040 |
} |
| 1041 |
const s = l < 0.5 ? d / (max + min) : d / (2 - max - min); |
| 1042 |
let h; |
| 1043 |
if (max === r) { |
| 1044 |
h = (g - b) / d + (g < b ? 6 : 0); |
| 1045 |
} else if (max === g) { |
| 1046 |
h = (b - r) / d + 2; |
| 1047 |
} else { |
| 1048 |
h = (r - g) / d + 4; |
| 1049 |
} |
| 1050 |
return { h: h * 60, s, l }; |
| 1051 |
} |
| 1052 |
function hslToHex(h, s, l) { |
| 1053 |
h = (h % 360 + 360) % 360; |
| 1054 |
s = Math.min(1, Math.max(0, s)); |
| 1055 |
l = Math.min(1, Math.max(0, l)); |
| 1056 |
const c = (1 - Math.abs(2 * l - 1)) * s; |
| 1057 |
const x = c * (1 - Math.abs(h / 60 % 2 - 1)); |
| 1058 |
const m = l - c / 2; |
| 1059 |
let r = 0; |
| 1060 |
let g = 0; |
| 1061 |
let b = 0; |
| 1062 |
if (h < 60) { |
| 1063 |
r = c; |
| 1064 |
g = x; |
| 1065 |
} else if (h < 120) { |
| 1066 |
r = x; |
| 1067 |
g = c; |
| 1068 |
} else if (h < 180) { |
| 1069 |
g = c; |
| 1070 |
b = x; |
| 1071 |
} else if (h < 240) { |
| 1072 |
g = x; |
| 1073 |
b = c; |
| 1074 |
} else if (h < 300) { |
| 1075 |
r = x; |
| 1076 |
b = c; |
| 1077 |
} else { |
| 1078 |
r = c; |
| 1079 |
b = x; |
| 1080 |
} |
| 1081 |
const channel = (v) => Math.round((v + m) * 255).toString(16).padStart(2, "0"); |
| 1082 |
return `#${channel(r)}${channel(g)}${channel(b)}`; |
| 1083 |
} |
| 1084 |
function backdropCss(background) { |
| 1085 |
const base = hexToHsl(background); |
| 1086 |
const mid = hslToHex( |
| 1087 |
base.h + STOP_55_DELTA.h, |
| 1088 |
base.s + STOP_55_DELTA.s, |
| 1089 |
base.l + STOP_55_DELTA.l |
| 1090 |
); |
| 1091 |
const bottom = hslToHex( |
| 1092 |
base.h + STOP_100_DELTA.h, |
| 1093 |
base.s + STOP_100_DELTA.s, |
| 1094 |
base.l + STOP_100_DELTA.l |
| 1095 |
); |
| 1096 |
return `linear-gradient(180deg, ${background} 0%, ${mid} 55%, ${bottom} 100%)`; |
| 1097 |
} |
| 1098 |
const TEXTURE_SIZE = 64; |
| 1099 |
const TUNING = { |
| 1100 |
/** |
| 1101 |
* Spawn rate (flakes/s) while the field is unsaturated, at the |
| 1102 |
* default particle count. Scaled linearly with the user's |
| 1103 |
* particle count so the pool fills in the same wall-clock time at |
| 1104 |
* every density. The pool cap is the real ceiling — once hit, |
| 1105 |
* spawn pauses until something melts or recycles. |
| 1106 |
*/ |
| 1107 |
spawnPerSecondAtDefault: 90, |
| 1108 |
/** The particle count `spawnPerSecondAtDefault` is calibrated for. */ |
| 1109 |
spawnCalibrationCount: 660, |
| 1110 |
/** |
| 1111 |
* Min / max vertical drift (px/s). Real snow falls slowly and |
| 1112 |
* reaches terminal velocity fast — air resistance dominates over |
| 1113 |
* gravity at flake mass — so velocity is modeled as a constant |
| 1114 |
* per particle rather than accelerating. Range chosen for an |
| 1115 |
* atmospheric feel rather than a hailstorm. |
| 1116 |
*/ |
| 1117 |
gravityMin: 28, |
| 1118 |
gravityMax: 72, |
| 1119 |
/** Period of the global wind sweep (seconds). */ |
| 1120 |
windPeriodSec: 11, |
| 1121 |
/** Per-particle sway amplitude (px/s target). */ |
| 1122 |
driftAmplitude: 32, |
| 1123 |
driftPeriodMin: 2.5, |
| 1124 |
driftPeriodMax: 5.5, |
| 1125 |
/** |
| 1126 |
* Max rotation speed (rad/s). Spheres are rotation-invariant by |
| 1127 |
* construction — kept tiny only so any minor bilinear-filter |
| 1128 |
* asymmetries don't lock to a fixed orientation across the field. |
| 1129 |
*/ |
| 1130 |
rotationMax: 0.2, |
| 1131 |
alphaMin: 0.7, |
| 1132 |
alphaMax: 1, |
| 1133 |
/** Melt duration once a stuck flake starts melting. */ |
| 1134 |
meltDurationSec: 1.8, |
| 1135 |
/** |
| 1136 |
* How long a flake stays stuck before it starts melting. Visible |
| 1137 |
* piles take time to build — a short lifetime barely lets a |
| 1138 |
* column reach 2–3 flakes before the bottom one melts. A small |
| 1139 |
* jitter is applied per flake so an entire windowful doesn't melt |
| 1140 |
* in lockstep. |
| 1141 |
*/ |
| 1142 |
stuckLifeSec: 9, |
| 1143 |
stuckLifeJitter: 2.5, |
| 1144 |
/** |
| 1145 |
* A small inset on the window top so flakes don't visibly overlap |
| 1146 |
* the title-bar drop shadow. |
| 1147 |
*/ |
| 1148 |
collisionMarginY: 2, |
| 1149 |
/** |
| 1150 |
* Width of one pile-height bucket in CSS px (surface-local X). |
| 1151 |
* Each surface keeps a Float32Array of bucket heights; a falling |
| 1152 |
* flake's bucket index is `floor((vpX - r.x) / bucket)`. 8 px is |
| 1153 |
* roughly half a flake wide — narrow enough that two flakes |
| 1154 |
* landing in the same column visibly stack rather than overlap, |
| 1155 |
* wide enough that buckets feel continuous rather than discrete |
| 1156 |
* bins. |
| 1157 |
*/ |
| 1158 |
pileBucketPx: 8, |
| 1159 |
/** |
| 1160 |
* Cap on per-column pile height in CSS px. Beyond this, further |
| 1161 |
* flakes still stick but don't push the pile higher — surfaces |
| 1162 |
* visually "saturate" with snow rather than growing unbounded |
| 1163 |
* towers. ~3 flakes deep at the largest default flake size reads |
| 1164 |
* as a small drift edge. |
| 1165 |
*/ |
| 1166 |
pileMaxPx: 48, |
| 1167 |
/** |
| 1168 |
* Fraction of a flake's size added to its bucket's pile height on |
| 1169 |
* landing. Successive flakes' centres sit only |
| 1170 |
* `pileContribution * size` apart, so 0.1 puts new flake centres |
| 1171 |
* just 10% of a diameter above the previous one. The bright cores |
| 1172 |
* (~30% of the size) heavily overlap, reading as a continuous |
| 1173 |
* mass of snow rather than a stack of discrete dots with visible |
| 1174 |
* interstitial halo. Lower = denser pile, slower vertical growth. |
| 1175 |
*/ |
| 1176 |
pileContribution: 0.1, |
| 1177 |
/** |
| 1178 |
* Fraction of `pileContribution` that bleeds into the two |
| 1179 |
* neighbor buckets — gives piles a natural slope instead of |
| 1180 |
* letting one column tower over its neighbors. |
| 1181 |
*/ |
| 1182 |
pileSpread: 0.4 |
| 1183 |
}; |
| 1184 |
const POOL_SIZE = SNOW_LIMITS.particleCount.max; |
| 1185 |
function buildSnowflakeTexture(pixi) { |
| 1186 |
const size = TEXTURE_SIZE; |
| 1187 |
const canvas = document.createElement("canvas"); |
| 1188 |
canvas.width = size; |
| 1189 |
canvas.height = size; |
| 1190 |
const ctx = canvas.getContext("2d"); |
| 1191 |
if (!ctx) { |
| 1192 |
return pixi.Texture.from(canvas); |
| 1193 |
} |
| 1194 |
const cx = size / 2; |
| 1195 |
const cy = size / 2; |
| 1196 |
const radius = size / 2 - 1; |
| 1197 |
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); |
| 1198 |
grad.addColorStop(0, "rgba(255, 255, 255, 1)"); |
| 1199 |
grad.addColorStop(0.3, "rgba(250, 252, 255, 0.9)"); |
| 1200 |
grad.addColorStop(0.6, "rgba(235, 245, 255, 0.32)"); |
| 1201 |
grad.addColorStop(0.88, "rgba(220, 232, 255, 0.07)"); |
| 1202 |
grad.addColorStop(1, "rgba(210, 228, 255, 0)"); |
| 1203 |
ctx.fillStyle = grad; |
| 1204 |
ctx.fillRect(0, 0, size, size); |
| 1205 |
return pixi.Texture.from(canvas); |
| 1206 |
} |
| 1207 |
function rand(a, b) { |
| 1208 |
return a + Math.random() * (b - a); |
| 1209 |
} |
| 1210 |
async function mountSnowScene(opts) { |
| 1211 |
const { container, pixi, getSurfaces } = opts; |
| 1212 |
const priorBackground = container.style.background; |
| 1213 |
container.style.background = backdropCss(opts.settings.background); |
| 1214 |
const tunables = { ...opts.settings }; |
| 1215 |
const app = new pixi.Application(); |
| 1216 |
try { |
| 1217 |
await app.init({ |
| 1218 |
resizeTo: container, |
| 1219 |
backgroundAlpha: 0, |
| 1220 |
antialias: true, |
| 1221 |
autoDensity: true, |
| 1222 |
resolution: Math.min(window.devicePixelRatio || 1, 2) |
| 1223 |
}); |
| 1224 |
} catch (err) { |
| 1225 |
container.style.background = priorBackground; |
| 1226 |
throw err; |
| 1227 |
} |
| 1228 |
container.appendChild(app.canvas); |
| 1229 |
app.canvas.style.position = "absolute"; |
| 1230 |
app.canvas.style.inset = "0"; |
| 1231 |
app.canvas.style.width = "100%"; |
| 1232 |
app.canvas.style.height = "100%"; |
| 1233 |
app.canvas.style.pointerEvents = "none"; |
| 1234 |
const texture = buildSnowflakeTexture(pixi); |
| 1235 |
const stage = new pixi.ParticleContainer({ |
| 1236 |
dynamicProperties: { |
| 1237 |
position: true, |
| 1238 |
vertex: true, |
| 1239 |
rotation: true, |
| 1240 |
color: true |
| 1241 |
} |
| 1242 |
}); |
| 1243 |
app.stage.addChild(stage); |
| 1244 |
const MAX = POOL_SIZE; |
| 1245 |
const pX = new Float32Array(MAX); |
| 1246 |
const pY = new Float32Array(MAX); |
| 1247 |
const pVX = new Float32Array(MAX); |
| 1248 |
const pVY = new Float32Array(MAX); |
| 1249 |
const pSize = new Float32Array(MAX); |
| 1250 |
const pRot = new Float32Array(MAX); |
| 1251 |
const pRotVel = new Float32Array(MAX); |
| 1252 |
const pDriftPhase = new Float32Array(MAX); |
| 1253 |
const pDriftFreq = new Float32Array(MAX); |
| 1254 |
const pDriftAmp = new Float32Array(MAX); |
| 1255 |
const pBaseAlpha = new Float32Array(MAX); |
| 1256 |
const pState = new Uint8Array(MAX); |
| 1257 |
const pAnchor = new Array(MAX); |
| 1258 |
const pAnchorDX = new Float32Array(MAX); |
| 1259 |
const pAnchorDY = new Float32Array(MAX); |
| 1260 |
const pStuckLife = new Float32Array(MAX); |
| 1261 |
const pMelt = new Float32Array(MAX); |
| 1262 |
const pSurfaceId = new Array(MAX); |
| 1263 |
const pBucket = new Int32Array(MAX); |
| 1264 |
const pPileAdd = new Float32Array(MAX); |
| 1265 |
const pPileRemaining = new Float32Array(MAX); |
| 1266 |
const particles = new Array(MAX); |
| 1267 |
const freeList = new Array(MAX); |
| 1268 |
for (let i = 0; i < MAX; i++) { |
| 1269 |
const particle = new pixi.Particle({ |
| 1270 |
texture, |
| 1271 |
anchorX: 0.5, |
| 1272 |
anchorY: 0.5, |
| 1273 |
// Free particles are hidden via alpha rather than removed |
| 1274 |
// from the container — the mutation is already on the |
| 1275 |
// GPU's dynamic-color path, so this is cheaper than |
| 1276 |
// churning the particle list. |
| 1277 |
alpha: 0, |
| 1278 |
tint: 16777215 |
| 1279 |
}); |
| 1280 |
stage.addParticle(particle); |
| 1281 |
particles[i] = particle; |
| 1282 |
pState[i] = 0; |
| 1283 |
pAnchor[i] = null; |
| 1284 |
pSurfaceId[i] = null; |
| 1285 |
pBucket[i] = -1; |
| 1286 |
pPileAdd[i] = 0; |
| 1287 |
pPileRemaining[i] = 0; |
| 1288 |
freeList[i] = MAX - 1 - i; |
| 1289 |
} |
| 1290 |
let freeCount = MAX; |
| 1291 |
const pileHeights = /* @__PURE__ */ new Map(); |
| 1292 |
const surfaces = []; |
| 1293 |
let surfacesDirty = true; |
| 1294 |
let canvasRect = app.canvas.getBoundingClientRect(); |
| 1295 |
function refreshCanvasRect() { |
| 1296 |
canvasRect = app.canvas.getBoundingClientRect(); |
| 1297 |
} |
| 1298 |
function refreshSurfacesIfDirty() { |
| 1299 |
if (!surfacesDirty) { |
| 1300 |
return; |
| 1301 |
} |
| 1302 |
surfaces.length = 0; |
| 1303 |
if (!getSurfaces) { |
| 1304 |
surfacesDirty = false; |
| 1305 |
return; |
| 1306 |
} |
| 1307 |
const all = getSurfaces(); |
| 1308 |
let liveIds = null; |
| 1309 |
for (let k = 0; k < all.length; k++) { |
| 1310 |
const s = all[k]; |
| 1311 |
if (s.face !== "top") { |
| 1312 |
continue; |
| 1313 |
} |
| 1314 |
if (s.rect.width <= 0 || s.rect.height <= 0) { |
| 1315 |
continue; |
| 1316 |
} |
| 1317 |
surfaces.push(s); |
| 1318 |
if (pileHeights.size > 0) { |
| 1319 |
if (liveIds === null) { |
| 1320 |
liveIds = /* @__PURE__ */ new Set(); |
| 1321 |
} |
| 1322 |
liveIds.add(s.id); |
| 1323 |
} |
| 1324 |
} |
| 1325 |
if (pileHeights.size > 0) { |
| 1326 |
pileHeights.forEach((_arr, id) => { |
| 1327 |
if (!liveIds || !liveIds.has(id)) { |
| 1328 |
pileHeights.delete(id); |
| 1329 |
} |
| 1330 |
}); |
| 1331 |
} |
| 1332 |
surfacesDirty = false; |
| 1333 |
} |
| 1334 |
function getPileForSurface(surface) { |
| 1335 |
const bucketCount = Math.max( |
| 1336 |
1, |
| 1337 |
Math.ceil(surface.rect.width / TUNING.pileBucketPx) |
| 1338 |
); |
| 1339 |
const existing = pileHeights.get(surface.id); |
| 1340 |
if (existing && existing.length === bucketCount) { |
| 1341 |
return existing; |
| 1342 |
} |
| 1343 |
const fresh = new Float32Array(bucketCount); |
| 1344 |
if (existing) { |
| 1345 |
const copyLen = Math.min(existing.length, bucketCount); |
| 1346 |
for (let i = 0; i < copyLen; i++) { |
| 1347 |
fresh[i] = existing[i]; |
| 1348 |
} |
| 1349 |
} |
| 1350 |
pileHeights.set(surface.id, fresh); |
| 1351 |
return fresh; |
| 1352 |
} |
| 1353 |
function spawn() { |
| 1354 |
if (freeCount === 0) { |
| 1355 |
return; |
| 1356 |
} |
| 1357 |
const idx = freeList[--freeCount]; |
| 1358 |
const w = app.canvas.clientWidth; |
| 1359 |
const sizeMax = tunables.flakeSize; |
| 1360 |
const sizeMin = sizeMax / 2; |
| 1361 |
pX[idx] = Math.random() * w; |
| 1362 |
pY[idx] = -rand(50, 180); |
| 1363 |
pVX[idx] = rand(-8, 8); |
| 1364 |
pVY[idx] = rand(TUNING.gravityMin, TUNING.gravityMax); |
| 1365 |
pSize[idx] = rand(sizeMin, sizeMax); |
| 1366 |
pRot[idx] = Math.random() * Math.PI * 2; |
| 1367 |
pRotVel[idx] = rand(-0.2, TUNING.rotationMax); |
| 1368 |
pDriftPhase[idx] = Math.random() * Math.PI * 2; |
| 1369 |
pDriftFreq[idx] = 2 * Math.PI / rand(TUNING.driftPeriodMin, TUNING.driftPeriodMax); |
| 1370 |
pDriftAmp[idx] = rand(6, TUNING.driftAmplitude); |
| 1371 |
pBaseAlpha[idx] = rand(TUNING.alphaMin, TUNING.alphaMax); |
| 1372 |
pMelt[idx] = 0; |
| 1373 |
pStuckLife[idx] = 0; |
| 1374 |
pAnchor[idx] = null; |
| 1375 |
pSurfaceId[idx] = null; |
| 1376 |
pBucket[idx] = -1; |
| 1377 |
pPileAdd[idx] = 0; |
| 1378 |
pState[idx] = 1; |
| 1379 |
pPileRemaining[idx] = 0; |
| 1380 |
const particle = particles[idx]; |
| 1381 |
if (!particle) { |
| 1382 |
return; |
| 1383 |
} |
| 1384 |
const scale = pSize[idx] / TEXTURE_SIZE; |
| 1385 |
particle.scaleX = scale; |
| 1386 |
particle.scaleY = scale; |
| 1387 |
particle.alpha = pBaseAlpha[idx]; |
| 1388 |
particle.rotation = pRot[idx]; |
| 1389 |
particle.x = pX[idx]; |
| 1390 |
particle.y = pY[idx]; |
| 1391 |
} |
| 1392 |
function decrementPileFor(idx) { |
| 1393 |
const sid = pSurfaceId[idx]; |
| 1394 |
if (sid === null) { |
| 1395 |
return; |
| 1396 |
} |
| 1397 |
const pile = pileHeights.get(sid); |
| 1398 |
if (!pile) { |
| 1399 |
return; |
| 1400 |
} |
| 1401 |
const b = pBucket[idx]; |
| 1402 |
if (b < 0 || b >= pile.length) { |
| 1403 |
return; |
| 1404 |
} |
| 1405 |
const add = pPileRemaining[idx]; |
| 1406 |
if (add <= 0) { |
| 1407 |
return; |
| 1408 |
} |
| 1409 |
const spread = add * TUNING.pileSpread; |
| 1410 |
pile[b] = Math.max(0, pile[b] - add); |
| 1411 |
if (b > 0) { |
| 1412 |
pile[b - 1] = Math.max(0, pile[b - 1] - spread); |
| 1413 |
} |
| 1414 |
if (b + 1 < pile.length) { |
| 1415 |
pile[b + 1] = Math.max(0, pile[b + 1] - spread); |
| 1416 |
} |
| 1417 |
pPileRemaining[idx] = 0; |
| 1418 |
} |
| 1419 |
function release(idx) { |
| 1420 |
decrementPileFor(idx); |
| 1421 |
pState[idx] = 0; |
| 1422 |
pAnchor[idx] = null; |
| 1423 |
pSurfaceId[idx] = null; |
| 1424 |
pBucket[idx] = -1; |
| 1425 |
pPileAdd[idx] = 0; |
| 1426 |
pPileRemaining[idx] = 0; |
| 1427 |
const particle = particles[idx]; |
| 1428 |
if (particle) { |
| 1429 |
particle.alpha = 0; |
| 1430 |
} |
| 1431 |
freeList[freeCount++] = idx; |
| 1432 |
} |
| 1433 |
function stick(idx, anchorEl, dx, pileHeight, surfaceId, bucket, pileAdd) { |
| 1434 |
pState[idx] = 2; |
| 1435 |
pAnchor[idx] = anchorEl; |
| 1436 |
pAnchorDX[idx] = dx; |
| 1437 |
pAnchorDY[idx] = TUNING.collisionMarginY - pileHeight; |
| 1438 |
pSurfaceId[idx] = surfaceId; |
| 1439 |
pBucket[idx] = bucket; |
| 1440 |
pPileAdd[idx] = pileAdd; |
| 1441 |
pPileRemaining[idx] = pileAdd; |
| 1442 |
pVX[idx] = 0; |
| 1443 |
pVY[idx] = 0; |
| 1444 |
pRotVel[idx] = 0; |
| 1445 |
pStuckLife[idx] = rand( |
| 1446 |
TUNING.stuckLifeSec - TUNING.stuckLifeJitter, |
| 1447 |
TUNING.stuckLifeSec + TUNING.stuckLifeJitter |
| 1448 |
); |
| 1449 |
} |
| 1450 |
function startMelt(idx) { |
| 1451 |
pState[idx] = 3; |
| 1452 |
pMelt[idx] = 0; |
| 1453 |
} |
| 1454 |
function detachToFalling(idx) { |
| 1455 |
decrementPileFor(idx); |
| 1456 |
pState[idx] = 1; |
| 1457 |
pAnchor[idx] = null; |
| 1458 |
pSurfaceId[idx] = null; |
| 1459 |
pBucket[idx] = -1; |
| 1460 |
pPileAdd[idx] = 0; |
| 1461 |
pPileRemaining[idx] = 0; |
| 1462 |
pVX[idx] = rand(-6, 6); |
| 1463 |
pVY[idx] = rand(TUNING.gravityMin, TUNING.gravityMax); |
| 1464 |
pRotVel[idx] = rand(-0.2, TUNING.rotationMax); |
| 1465 |
} |
| 1466 |
function collideWithSurfaces(idx, prevY) { |
| 1467 |
const vpX = pX[idx] + canvasRect.left; |
| 1468 |
const vpY = pY[idx] + canvasRect.top; |
| 1469 |
const prevVpY = prevY + canvasRect.top; |
| 1470 |
for (let k = 0; k < surfaces.length; k++) { |
| 1471 |
const s = surfaces[k]; |
| 1472 |
const r = s.rect; |
| 1473 |
if (vpX < r.x || vpX > r.x + r.width) { |
| 1474 |
continue; |
| 1475 |
} |
| 1476 |
const pile = getPileForSurface(s); |
| 1477 |
let bucket = Math.floor((vpX - r.x) / TUNING.pileBucketPx); |
| 1478 |
if (bucket < 0) { |
| 1479 |
bucket = 0; |
| 1480 |
} else if (bucket >= pile.length) { |
| 1481 |
bucket = pile.length - 1; |
| 1482 |
} |
| 1483 |
const pileHeight = pile[bucket]; |
| 1484 |
const top = r.y + TUNING.collisionMarginY - pileHeight; |
| 1485 |
if (prevVpY <= top && vpY >= top) { |
| 1486 |
const add = pSize[idx] * TUNING.pileContribution; |
| 1487 |
const spread = add * TUNING.pileSpread; |
| 1488 |
stick(idx, s.element, vpX - r.x, pileHeight, s.id, bucket, add); |
| 1489 |
pile[bucket] = Math.min(TUNING.pileMaxPx, pile[bucket] + add); |
| 1490 |
if (bucket > 0) { |
| 1491 |
pile[bucket - 1] = Math.min( |
| 1492 |
TUNING.pileMaxPx, |
| 1493 |
pile[bucket - 1] + spread |
| 1494 |
); |
| 1495 |
} |
| 1496 |
if (bucket + 1 < pile.length) { |
| 1497 |
pile[bucket + 1] = Math.min( |
| 1498 |
TUNING.pileMaxPx, |
| 1499 |
pile[bucket + 1] + spread |
| 1500 |
); |
| 1501 |
} |
| 1502 |
return true; |
| 1503 |
} |
| 1504 |
} |
| 1505 |
return false; |
| 1506 |
} |
| 1507 |
let elapsed = 0; |
| 1508 |
let lastRectRefresh = -1; |
| 1509 |
let spawnAccum = 0; |
| 1510 |
let animating = !opts.prefersReducedMotion; |
| 1511 |
function spawnPerSecond() { |
| 1512 |
return TUNING.spawnPerSecondAtDefault * tunables.particleCount / TUNING.spawnCalibrationCount; |
| 1513 |
} |
| 1514 |
if (!animating) { |
| 1515 |
const staticCount = tunables.particleCount * 0.35; |
| 1516 |
for (let s = 0; s < staticCount; s++) { |
| 1517 |
spawn(); |
| 1518 |
} |
| 1519 |
} |
| 1520 |
function tick(ticker) { |
| 1521 |
let dt = ticker.deltaMS / 1e3; |
| 1522 |
if (dt > 0.1) { |
| 1523 |
dt = 0.1; |
| 1524 |
} |
| 1525 |
elapsed += dt; |
| 1526 |
if (elapsed - lastRectRefresh > 0.05) { |
| 1527 |
surfacesDirty = true; |
| 1528 |
lastRectRefresh = elapsed; |
| 1529 |
} |
| 1530 |
refreshCanvasRect(); |
| 1531 |
refreshSurfacesIfDirty(); |
| 1532 |
const wind = Math.sin(elapsed / TUNING.windPeriodSec * Math.PI * 2) * tunables.wind; |
| 1533 |
if (animating) { |
| 1534 |
spawnAccum += dt * spawnPerSecond(); |
| 1535 |
while (spawnAccum >= 1) { |
| 1536 |
if (MAX - freeCount >= tunables.particleCount) { |
| 1537 |
spawnAccum = 0; |
| 1538 |
break; |
| 1539 |
} |
| 1540 |
spawn(); |
| 1541 |
spawnAccum -= 1; |
| 1542 |
} |
| 1543 |
} |
| 1544 |
const w = app.canvas.clientWidth; |
| 1545 |
const h = app.canvas.clientHeight; |
| 1546 |
for (let idx = 0; idx < MAX; idx++) { |
| 1547 |
const st = pState[idx]; |
| 1548 |
if (st === 0) { |
| 1549 |
continue; |
| 1550 |
} |
| 1551 |
const particle = particles[idx]; |
| 1552 |
if (!particle) { |
| 1553 |
continue; |
| 1554 |
} |
| 1555 |
if (st === 1) { |
| 1556 |
const prevY = pY[idx]; |
| 1557 |
const sway = Math.sin(elapsed * pDriftFreq[idx] + pDriftPhase[idx]) * pDriftAmp[idx]; |
| 1558 |
pVX[idx] += (wind + sway - pVX[idx]) * Math.min(1, dt * 1.5); |
| 1559 |
pX[idx] += pVX[idx] * dt; |
| 1560 |
pY[idx] += pVY[idx] * dt; |
| 1561 |
pRot[idx] += pRotVel[idx] * dt; |
| 1562 |
if (pX[idx] < -16) { |
| 1563 |
pX[idx] += w + 32; |
| 1564 |
} else if (pX[idx] > w + 16) { |
| 1565 |
pX[idx] -= w + 32; |
| 1566 |
} |
| 1567 |
if (collideWithSurfaces(idx, prevY)) ; |
| 1568 |
else if (pY[idx] > h + 24) { |
| 1569 |
release(idx); |
| 1570 |
continue; |
| 1571 |
} |
| 1572 |
if (pState[idx] === 1) { |
| 1573 |
particle.x = pX[idx]; |
| 1574 |
particle.y = pY[idx]; |
| 1575 |
particle.rotation = pRot[idx]; |
| 1576 |
} |
| 1577 |
} |
| 1578 |
if (pState[idx] === 2) { |
| 1579 |
const anchorEl = pAnchor[idx]; |
| 1580 |
if (anchorEl) { |
| 1581 |
if (!anchorEl.isConnected) { |
| 1582 |
detachToFalling(idx); |
| 1583 |
} else if (anchorEl.offsetParent === null) { |
| 1584 |
startMelt(idx); |
| 1585 |
} else { |
| 1586 |
const arect = anchorEl.getBoundingClientRect(); |
| 1587 |
if (pAnchorDX[idx] < 0 || pAnchorDX[idx] > arect.width) { |
| 1588 |
detachToFalling(idx); |
| 1589 |
continue; |
| 1590 |
} |
| 1591 |
const ax = arect.left - canvasRect.left + pAnchorDX[idx]; |
| 1592 |
const ay = arect.top - canvasRect.top + pAnchorDY[idx]; |
| 1593 |
pX[idx] = ax; |
| 1594 |
pY[idx] = ay; |
| 1595 |
particle.x = ax; |
| 1596 |
particle.y = ay; |
| 1597 |
} |
| 1598 |
} else { |
| 1599 |
particle.x = pX[idx]; |
| 1600 |
particle.y = pY[idx]; |
| 1601 |
} |
| 1602 |
if (pState[idx] === 2) { |
| 1603 |
pStuckLife[idx] -= dt; |
| 1604 |
if (pStuckLife[idx] <= 0) { |
| 1605 |
startMelt(idx); |
| 1606 |
} |
| 1607 |
} |
| 1608 |
} |
| 1609 |
if (pState[idx] === 3) { |
| 1610 |
pMelt[idx] += dt / TUNING.meltDurationSec; |
| 1611 |
const t = pMelt[idx] > 1 ? 1 : pMelt[idx]; |
| 1612 |
particle.alpha = pBaseAlpha[idx] * (1 - t); |
| 1613 |
const meltScale = pSize[idx] / TEXTURE_SIZE * (1 - t * 0.6); |
| 1614 |
particle.scaleX = meltScale; |
| 1615 |
particle.scaleY = meltScale; |
| 1616 |
if (pPileRemaining[idx] > 0 && pSurfaceId[idx] !== null) { |
| 1617 |
const meltStep = dt / TUNING.meltDurationSec; |
| 1618 |
const rawDelta = pPileAdd[idx] * meltStep; |
| 1619 |
const delta = rawDelta < pPileRemaining[idx] ? rawDelta : pPileRemaining[idx]; |
| 1620 |
pPileRemaining[idx] -= delta; |
| 1621 |
const pile = pileHeights.get(pSurfaceId[idx]); |
| 1622 |
if (pile && pBucket[idx] >= 0 && pBucket[idx] < pile.length) { |
| 1623 |
const bk = pBucket[idx]; |
| 1624 |
const spread = delta * TUNING.pileSpread; |
| 1625 |
pile[bk] = Math.max(0, pile[bk] - delta); |
| 1626 |
if (bk > 0) { |
| 1627 |
pile[bk - 1] = Math.max( |
| 1628 |
0, |
| 1629 |
pile[bk - 1] - spread |
| 1630 |
); |
| 1631 |
} |
| 1632 |
if (bk + 1 < pile.length) { |
| 1633 |
pile[bk + 1] = Math.max( |
| 1634 |
0, |
| 1635 |
pile[bk + 1] - spread |
| 1636 |
); |
| 1637 |
} |
| 1638 |
} |
| 1639 |
const mySid = pSurfaceId[idx]; |
| 1640 |
const myBucket = pBucket[idx]; |
| 1641 |
const myDY = pAnchorDY[idx]; |
| 1642 |
for (let j = 0; j < MAX; j++) { |
| 1643 |
if (pState[j] === 2 && pSurfaceId[j] === mySid && pBucket[j] === myBucket && pAnchorDY[j] < myDY) { |
| 1644 |
pAnchorDY[j] += delta; |
| 1645 |
} |
| 1646 |
} |
| 1647 |
} |
| 1648 |
if (t >= 1) { |
| 1649 |
release(idx); |
| 1650 |
} |
| 1651 |
} |
| 1652 |
} |
| 1653 |
} |
| 1654 |
app.ticker.add(tick); |
| 1655 |
if (!animating) { |
| 1656 |
app.ticker.update(); |
| 1657 |
app.ticker.stop(); |
| 1658 |
} |
| 1659 |
let destroyed = false; |
| 1660 |
return { |
| 1661 |
setAnimating(next) { |
| 1662 |
if (destroyed) { |
| 1663 |
return; |
| 1664 |
} |
| 1665 |
animating = next && !opts.prefersReducedMotion; |
| 1666 |
if (animating) { |
| 1667 |
app.ticker.start(); |
| 1668 |
} else { |
| 1669 |
app.ticker.stop(); |
| 1670 |
} |
| 1671 |
}, |
| 1672 |
applySettings(next) { |
| 1673 |
if (destroyed) { |
| 1674 |
return; |
| 1675 |
} |
| 1676 |
tunables.wind = next.wind; |
| 1677 |
tunables.particleCount = next.particleCount; |
| 1678 |
tunables.flakeSize = next.flakeSize; |
| 1679 |
if (next.background !== tunables.background) { |
| 1680 |
tunables.background = next.background; |
| 1681 |
container.style.background = backdropCss(next.background); |
| 1682 |
} |
| 1683 |
}, |
| 1684 |
markSurfacesDirty() { |
| 1685 |
surfacesDirty = true; |
| 1686 |
}, |
| 1687 |
detachFlakesAnchoredTo(element) { |
| 1688 |
if (destroyed) { |
| 1689 |
return; |
| 1690 |
} |
| 1691 |
surfacesDirty = true; |
| 1692 |
for (let i = 0; i < MAX; i++) { |
| 1693 |
if (pState[i] === 2 && pAnchor[i] === element) { |
| 1694 |
detachToFalling(i); |
| 1695 |
} |
| 1696 |
} |
| 1697 |
}, |
| 1698 |
destroy() { |
| 1699 |
if (destroyed) { |
| 1700 |
return; |
| 1701 |
} |
| 1702 |
destroyed = true; |
| 1703 |
app.ticker.stop(); |
| 1704 |
app.ticker.remove(tick); |
| 1705 |
app.destroy( |
| 1706 |
{ removeView: true }, |
| 1707 |
{ children: true, texture: true, textureSource: true } |
| 1708 |
); |
| 1709 |
for (let i = 0; i < MAX; i++) { |
| 1710 |
particles[i] = null; |
| 1711 |
pAnchor[i] = null; |
| 1712 |
} |
| 1713 |
container.style.background = priorBackground; |
| 1714 |
} |
| 1715 |
}; |
| 1716 |
} |
| 1717 |
const WALLPAPER_ID = "wp-snow"; |
| 1718 |
const NAMESPACE = "desktop-mode/snow"; |
| 1719 |
const PREVIEW = backdropCss(SNOW_DEFAULTS.background); |
| 1720 |
const PREVIEW_PARTICLES = 140; |
| 1721 |
function surfacesSupplier() { |
| 1722 |
const api = window.wp?.desktop; |
| 1723 |
if (!api || typeof api.getWallpaperSurfaces !== "function") { |
| 1724 |
return null; |
| 1725 |
} |
| 1726 |
return () => api.getWallpaperSurfaces(); |
| 1727 |
} |
| 1728 |
function wireSceneHooks(scene) { |
| 1729 |
const visibilityHandler = (...args) => { |
| 1730 |
const detail = args[0]; |
| 1731 |
if (!detail || detail.id !== WALLPAPER_ID) { |
| 1732 |
return; |
| 1733 |
} |
| 1734 |
scene.setAnimating(detail.state === "visible"); |
| 1735 |
}; |
| 1736 |
addAction( |
| 1737 |
HOOKS.WALLPAPER_VISIBILITY, |
| 1738 |
`${NAMESPACE}/visibility`, |
| 1739 |
visibilityHandler |
| 1740 |
); |
| 1741 |
const detachHandler = (...args) => { |
| 1742 |
const detail = args[0]; |
| 1743 |
if (!detail || !detail.element) { |
| 1744 |
return; |
| 1745 |
} |
| 1746 |
scene.detachFlakesAnchoredTo(detail.element); |
| 1747 |
}; |
| 1748 |
addAction( |
| 1749 |
HOOKS.WINDOW_CLOSING, |
| 1750 |
`${NAMESPACE}/window-closing`, |
| 1751 |
detachHandler |
| 1752 |
); |
| 1753 |
addAction( |
| 1754 |
HOOKS.WINDOW_MINIMIZED, |
| 1755 |
`${NAMESPACE}/window-minimized`, |
| 1756 |
detachHandler |
| 1757 |
); |
| 1758 |
const dirtyHandler = () => { |
| 1759 |
scene.markSurfacesDirty(); |
| 1760 |
}; |
| 1761 |
addAction( |
| 1762 |
HOOKS.WINDOW_BOUNDS_CHANGED, |
| 1763 |
`${NAMESPACE}/bounds-changed`, |
| 1764 |
dirtyHandler |
| 1765 |
); |
| 1766 |
addAction( |
| 1767 |
HOOKS.WINDOW_RESTORED, |
| 1768 |
`${NAMESPACE}/window-restored`, |
| 1769 |
dirtyHandler |
| 1770 |
); |
| 1771 |
addAction( |
| 1772 |
HOOKS.WINDOW_MAXIMIZED, |
| 1773 |
`${NAMESPACE}/window-maximized`, |
| 1774 |
dirtyHandler |
| 1775 |
); |
| 1776 |
addAction( |
| 1777 |
HOOKS.WINDOW_UNMAXIMIZED, |
| 1778 |
`${NAMESPACE}/window-unmaximized`, |
| 1779 |
dirtyHandler |
| 1780 |
); |
| 1781 |
addAction( |
| 1782 |
HOOKS.WINDOW_FULLSCREEN_ENTERED, |
| 1783 |
`${NAMESPACE}/window-fullscreen-entered`, |
| 1784 |
dirtyHandler |
| 1785 |
); |
| 1786 |
addAction( |
| 1787 |
HOOKS.WINDOW_FULLSCREEN_EXITED, |
| 1788 |
`${NAMESPACE}/window-fullscreen-exited`, |
| 1789 |
dirtyHandler |
| 1790 |
); |
| 1791 |
const widgetUnmountingHandler = (...args) => { |
| 1792 |
const detail = args[0]; |
| 1793 |
if (!detail || !detail.id) { |
| 1794 |
return; |
| 1795 |
} |
| 1796 |
const safeId = window.CSS && typeof CSS.escape === "function" ? CSS.escape(detail.id) : String(detail.id).replace(/"/g, '\\"'); |
| 1797 |
const card = document.querySelector( |
| 1798 |
`[data-widget-id="${safeId}"]` |
| 1799 |
); |
| 1800 |
if (!card) { |
| 1801 |
return; |
| 1802 |
} |
| 1803 |
scene.detachFlakesAnchoredTo(card); |
| 1804 |
}; |
| 1805 |
addAction( |
| 1806 |
HOOKS.WIDGET_UNMOUNTING, |
| 1807 |
`${NAMESPACE}/widget-unmounting`, |
| 1808 |
widgetUnmountingHandler |
| 1809 |
); |
| 1810 |
const settingsHandler = (...args) => { |
| 1811 |
const detail = args[0]; |
| 1812 |
if (!detail || detail.id !== WALLPAPER_ID) { |
| 1813 |
return; |
| 1814 |
} |
| 1815 |
scene.applySettings(sanitizeSnowSettings(detail.settings)); |
| 1816 |
}; |
| 1817 |
addAction( |
| 1818 |
HOOKS.WALLPAPER_SETTINGS_CHANGED, |
| 1819 |
`${NAMESPACE}/settings-changed`, |
| 1820 |
settingsHandler |
| 1821 |
); |
| 1822 |
return () => { |
| 1823 |
removeAction(HOOKS.WALLPAPER_VISIBILITY, `${NAMESPACE}/visibility`); |
| 1824 |
removeAction(HOOKS.WINDOW_CLOSING, `${NAMESPACE}/window-closing`); |
| 1825 |
removeAction(HOOKS.WINDOW_MINIMIZED, `${NAMESPACE}/window-minimized`); |
| 1826 |
removeAction(HOOKS.WINDOW_RESTORED, `${NAMESPACE}/window-restored`); |
| 1827 |
removeAction(HOOKS.WINDOW_MAXIMIZED, `${NAMESPACE}/window-maximized`); |
| 1828 |
removeAction( |
| 1829 |
HOOKS.WINDOW_UNMAXIMIZED, |
| 1830 |
`${NAMESPACE}/window-unmaximized` |
| 1831 |
); |
| 1832 |
removeAction( |
| 1833 |
HOOKS.WINDOW_FULLSCREEN_ENTERED, |
| 1834 |
`${NAMESPACE}/window-fullscreen-entered` |
| 1835 |
); |
| 1836 |
removeAction( |
| 1837 |
HOOKS.WINDOW_FULLSCREEN_EXITED, |
| 1838 |
`${NAMESPACE}/window-fullscreen-exited` |
| 1839 |
); |
| 1840 |
removeAction( |
| 1841 |
HOOKS.WINDOW_BOUNDS_CHANGED, |
| 1842 |
`${NAMESPACE}/bounds-changed` |
| 1843 |
); |
| 1844 |
removeAction( |
| 1845 |
HOOKS.WIDGET_UNMOUNTING, |
| 1846 |
`${NAMESPACE}/widget-unmounting` |
| 1847 |
); |
| 1848 |
removeAction( |
| 1849 |
HOOKS.WALLPAPER_SETTINGS_CHANGED, |
| 1850 |
`${NAMESPACE}/settings-changed` |
| 1851 |
); |
| 1852 |
}; |
| 1853 |
} |
| 1854 |
function rangeField(label, limits, step, value, onChange) { |
| 1855 |
const field = document.createElement("wpd-range-field"); |
| 1856 |
field.setAttribute("label", label); |
| 1857 |
field.setAttribute("min", String(limits.min)); |
| 1858 |
field.setAttribute("max", String(limits.max)); |
| 1859 |
field.setAttribute("step", String(step)); |
| 1860 |
field.setAttribute("value", String(value)); |
| 1861 |
field.addEventListener("wpd-range-change", (e) => { |
| 1862 |
onChange(e.detail.value); |
| 1863 |
}); |
| 1864 |
return field; |
| 1865 |
} |
| 1866 |
function renderSnowConfig(container, ctx) { |
| 1867 |
let current = sanitizeSnowSettings(ctx.settings); |
| 1868 |
const set = (partial) => { |
| 1869 |
current = { ...current, ...partial }; |
| 1870 |
ctx.setSettings(partial); |
| 1871 |
}; |
| 1872 |
const windField = rangeField( |
| 1873 |
__("Wind"), |
| 1874 |
SNOW_LIMITS.wind, |
| 1875 |
1, |
| 1876 |
current.wind, |
| 1877 |
(value) => set({ wind: value }) |
| 1878 |
); |
| 1879 |
const particlesField = rangeField( |
| 1880 |
__("Snowflakes"), |
| 1881 |
SNOW_LIMITS.particleCount, |
| 1882 |
10, |
| 1883 |
current.particleCount, |
| 1884 |
(value) => set({ particleCount: Math.round(value) }) |
| 1885 |
); |
| 1886 |
const sizeField = rangeField( |
| 1887 |
__("Flake size"), |
| 1888 |
SNOW_LIMITS.flakeSize, |
| 1889 |
1, |
| 1890 |
current.flakeSize, |
| 1891 |
(value) => set({ flakeSize: value }) |
| 1892 |
); |
| 1893 |
const colorField = document.createElement("wpd-color-field"); |
| 1894 |
colorField.setAttribute("label", __("Background color")); |
| 1895 |
colorField.setAttribute("value", current.background); |
| 1896 |
colorField.addEventListener("wpd-color-change", (e) => { |
| 1897 |
const value = e.detail.value; |
| 1898 |
set({ background: sanitizeSnowSettings({ background: value }).background }); |
| 1899 |
}); |
| 1900 |
const reset = document.createElement("wpd-button"); |
| 1901 |
reset.setAttribute("variant", "ghost"); |
| 1902 |
reset.style.alignSelf = "flex-start"; |
| 1903 |
reset.style.marginTop = "4px"; |
| 1904 |
reset.textContent = __("Reset to defaults"); |
| 1905 |
reset.addEventListener("click", () => { |
| 1906 |
set({ ...SNOW_DEFAULTS }); |
| 1907 |
windField.setAttribute("value", String(SNOW_DEFAULTS.wind)); |
| 1908 |
particlesField.setAttribute( |
| 1909 |
"value", |
| 1910 |
String(SNOW_DEFAULTS.particleCount) |
| 1911 |
); |
| 1912 |
sizeField.setAttribute("value", String(SNOW_DEFAULTS.flakeSize)); |
| 1913 |
colorField.setAttribute("value", SNOW_DEFAULTS.background); |
| 1914 |
}); |
| 1915 |
container.appendChild(windField); |
| 1916 |
container.appendChild(particlesField); |
| 1917 |
container.appendChild(sizeField); |
| 1918 |
container.appendChild(colorField); |
| 1919 |
container.appendChild(reset); |
| 1920 |
return () => { |
| 1921 |
}; |
| 1922 |
} |
| 1923 |
const def = { |
| 1924 |
id: WALLPAPER_ID, |
| 1925 |
label: __("Snow"), |
| 1926 |
type: "canvas", |
| 1927 |
preview: PREVIEW, |
| 1928 |
previewParams: { particleCount: PREVIEW_PARTICLES }, |
| 1929 |
/** |
| 1930 |
* Live tile preview for the OS Settings picker — the real |
| 1931 |
* simulation at tile scale, minus surface collision (surface |
| 1932 |
* rects are viewport-space and meaningless inside a tile) and at |
| 1933 |
* a fraction of the field density. |
| 1934 |
*/ |
| 1935 |
renderPreview: async (container, ctx) => { |
| 1936 |
const pixi = getPixi(); |
| 1937 |
if (!pixi) { |
| 1938 |
return () => { |
| 1939 |
}; |
| 1940 |
} |
| 1941 |
const settings = sanitizeSnowSettings(ctx.settings); |
| 1942 |
const rawCount = ctx.params.particleCount; |
| 1943 |
const previewCount = typeof rawCount === "number" && Number.isFinite(rawCount) ? rawCount : PREVIEW_PARTICLES; |
| 1944 |
const scene = await mountSnowScene({ |
| 1945 |
container, |
| 1946 |
pixi, |
| 1947 |
settings: sanitizeSnowSettings({ |
| 1948 |
...settings, |
| 1949 |
particleCount: previewCount |
| 1950 |
}), |
| 1951 |
prefersReducedMotion: ctx.prefersReducedMotion, |
| 1952 |
getSurfaces: null |
| 1953 |
}); |
| 1954 |
return () => scene.destroy(); |
| 1955 |
}, |
| 1956 |
needs: ["pixijs"], |
| 1957 |
mount: async (container, ctx) => { |
| 1958 |
const pixi = getPixi(); |
| 1959 |
if (!pixi) { |
| 1960 |
return () => { |
| 1961 |
}; |
| 1962 |
} |
| 1963 |
const scene = await mountSnowScene({ |
| 1964 |
container, |
| 1965 |
pixi, |
| 1966 |
settings: sanitizeSnowSettings(ctx.settings), |
| 1967 |
prefersReducedMotion: ctx.prefersReducedMotion, |
| 1968 |
getSurfaces: surfacesSupplier() |
| 1969 |
}); |
| 1970 |
const unwireHooks = wireSceneHooks(scene); |
| 1971 |
return () => { |
| 1972 |
unwireHooks(); |
| 1973 |
scene.destroy(); |
| 1974 |
}; |
| 1975 |
}, |
| 1976 |
renderConfig: renderSnowConfig |
| 1977 |
}; |
| 1978 |
window.desktopModeWallpapers = window.desktopModeWallpapers || {}; |
| 1979 |
window.desktopModeWallpapers[WALLPAPER_ID] = def; |
| 1980 |
})(); |
| 1981 |
|