UNPKG

@fluritheme/fluri

Version:

Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.

118 lines • 3.96 kB
const maxSourceLength = 4 * 1024 * 1024; /** Raster-only data URLs avoid SVG script/external-resource behavior. Remote images require HTTPS. */ export function isSafeTextureSource(value) { if (typeof value !== 'string' || value.length === 0 || value.length > maxSourceLength || value !== value.trim()) return false; if (/^data:image\/(?:png|jpeg|webp);base64,[a-z\d+/]+={0,2}$/i.test(value)) return true; try { const url = new URL(value); if (url.username || url.password) return false; if (url.protocol === 'https:') return true; if (url.protocol === 'blob:') { if (/^blob:null\/[a-z\d-]+$/i.test(value)) return true; const origin = new URL(value.slice(5)); return (!origin.username && !origin.password && (origin.protocol === 'https:' || origin.protocol === 'http:')); } } catch { return false; } return false; } /** Owns exactly one asynchronous load. Stale callbacks cannot resurrect a disposed texture. */ export class SnowTexture { doc; onError; requested; pending; drawable; generation = 0; currentStatus = 'idle'; constructor(doc, onError = () => { }) { this.doc = doc; this.onError = onError; } get status() { return this.currentStatus; } get image() { return this.drawable; } setSource(source) { if (this.currentStatus === 'destroyed' || this.requested === source) return; this.clearImage(); this.requested = source; if (source === undefined) { this.currentStatus = 'idle'; return; } if (!isSafeTextureSource(source)) { this.fail(new TypeError('Snow texture must be a safe HTTPS, blob, or raster data URL')); return; } const image = this.doc.createElement('img'); const generation = this.generation; this.pending = image; this.currentStatus = 'loading'; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; if (new URL(source).protocol === 'https:') image.crossOrigin = 'anonymous'; image.onload = () => { if (generation !== this.generation || this.currentStatus === 'destroyed') return; if (image.naturalWidth <= 0 || image.naturalHeight <= 0 || image.naturalWidth > 2048 || image.naturalHeight > 2048) { this.fail(new RangeError('Snow texture dimensions must be between 1 and 2048 pixels')); return; } image.onload = null; image.onerror = null; this.drawable = image; this.pending = undefined; this.currentStatus = 'ready'; }; image.onerror = () => { if (generation !== this.generation || this.currentStatus === 'destroyed') return; this.fail(new Error('Snow texture failed to load or was blocked by CORS or the site content security policy')); }; image.src = source; } fail(error) { this.clearImage(); this.currentStatus = 'error'; this.onError(error); } clearImage() { this.generation++; for (const image of [this.pending, this.drawable]) if (image) { image.onload = null; image.onerror = null; image.removeAttribute('src'); } this.pending = undefined; this.drawable = undefined; } destroy() { if (this.currentStatus === 'destroyed') return; this.clearImage(); this.requested = undefined; this.currentStatus = 'destroyed'; } } //# sourceMappingURL=Texture.js.map