176 lines
5.6 KiB
JavaScript
176 lines
5.6 KiB
JavaScript
/**
|
||
* hypn0.js — Основной клиентский скрипт для hypn0.xyz
|
||
* Обеспечивает интеграцию HTMX, работу с Declarative Shadow DOM
|
||
* и ленивую асинхронную подгрузку SVG-халфтонов через Alpine.js.
|
||
*/
|
||
|
||
// Автоматическая передача CSRF-токена в AJAX-запросах HTMX
|
||
document.addEventListener('htmx:configRequest', function(evt) {
|
||
var csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
|
||
var token = csrfInput ? csrfInput.value : null;
|
||
if (!token) {
|
||
var match = document.cookie.match(/csrftoken=([^;]+)/);
|
||
if (match) token = match[1];
|
||
}
|
||
if (token) {
|
||
evt.detail.headers['X-CSRFToken'] = token;
|
||
}
|
||
});
|
||
|
||
// Полифилл / обработка Declarative Shadow DOM для старых браузеров и динамических вставок
|
||
function attachShadowRoots(root) {
|
||
(root || document).querySelectorAll('template[shadowrootmode]').forEach(function(tmpl) {
|
||
if (!tmpl.parentElement.shadowRoot) {
|
||
var mode = tmpl.getAttribute('shadowrootmode') || 'open';
|
||
var shadow = tmpl.parentElement.attachShadow({ mode: mode });
|
||
shadow.appendChild(tmpl.content.cloneNode(true));
|
||
tmpl.remove();
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
attachShadowRoots(document);
|
||
});
|
||
|
||
// Обработка HTMX swap: инициализация теневого корня и Alpine.js компонентов в новом DOM-фрагменте
|
||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||
attachShadowRoots(evt.detail.target);
|
||
if (window.Alpine && evt.detail && evt.detail.target) {
|
||
window.Alpine.initTree(evt.detail.target);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* hypn0LazySvg — Alpine-компонент ленивой асинхронной загрузки SVG в Shadow DOM.
|
||
* Защищает от лагов быстрого скролла (AbortController + debounce),
|
||
* размазывает пиковые сетевые всплески (джиттер) и изолирует стили/ID.
|
||
*/
|
||
function hypn0LazySvg(svgUrl) {
|
||
return {
|
||
svgUrl: svgUrl || '',
|
||
loaded: false,
|
||
error: false,
|
||
_observer: null,
|
||
_controller: null,
|
||
_timer: null,
|
||
|
||
init() {
|
||
if (!this.svgUrl) return;
|
||
var el = this.$el;
|
||
|
||
if (!('IntersectionObserver' in window)) {
|
||
this.fetchSvg(el);
|
||
return;
|
||
}
|
||
|
||
var self = this;
|
||
this._observer = new IntersectionObserver(function(entries) {
|
||
entries.forEach(function(entry) {
|
||
if (entry.isIntersecting) {
|
||
var jitter = Math.floor(Math.random() * 60);
|
||
self._timer = setTimeout(function() {
|
||
self.fetchSvg(el);
|
||
}, 120 + jitter);
|
||
} else {
|
||
if (self._timer) {
|
||
clearTimeout(self._timer);
|
||
self._timer = null;
|
||
}
|
||
if (self._controller) {
|
||
self._controller.abort();
|
||
self._controller = null;
|
||
}
|
||
}
|
||
});
|
||
}, {
|
||
rootMargin: '120px 0px 120px 0px',
|
||
threshold: 0.01
|
||
});
|
||
|
||
this._observer.observe(el);
|
||
},
|
||
|
||
fetchSvg(el) {
|
||
if (this.loaded) return;
|
||
if (this._controller) this._controller.abort();
|
||
this._controller = new AbortController();
|
||
|
||
var self = this;
|
||
fetch(this.svgUrl, { signal: this._controller.signal })
|
||
.then(function(resp) {
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
return resp.text();
|
||
})
|
||
.then(function(svgText) {
|
||
if (!svgText) return;
|
||
|
||
var shadow = el.shadowRoot;
|
||
if (!shadow) {
|
||
shadow = el.attachShadow({ mode: 'open' });
|
||
}
|
||
|
||
var style = document.createElement('style');
|
||
style.textContent = [
|
||
':host {',
|
||
' display: flex;',
|
||
' width: 100%;',
|
||
' height: 100%;',
|
||
' align-items: center;',
|
||
' justify-content: center;',
|
||
' pointer-events: auto;',
|
||
' opacity: 0;',
|
||
' transition: opacity 0.35s ease-out;',
|
||
'}',
|
||
':host(.is-loaded), :host([data-loaded]) {',
|
||
' opacity: 1;',
|
||
'}',
|
||
'svg {',
|
||
' width: 100%;',
|
||
' height: 100%;',
|
||
' object-fit: contain;',
|
||
'}',
|
||
':host(:hover) svg, svg:hover {',
|
||
' --hypn0-play: running !important;',
|
||
'}'
|
||
].join('\n');
|
||
|
||
var parser = new DOMParser();
|
||
var doc = parser.parseFromString(svgText, 'image/svg+xml');
|
||
var svgEl = doc.querySelector('svg');
|
||
|
||
shadow.innerHTML = '';
|
||
shadow.appendChild(style);
|
||
if (svgEl) {
|
||
shadow.appendChild(svgEl);
|
||
} else {
|
||
var container = document.createElement('div');
|
||
container.innerHTML = svgText;
|
||
var innerSvg = container.querySelector('svg');
|
||
if (innerSvg) shadow.appendChild(innerSvg);
|
||
}
|
||
|
||
self.loaded = true;
|
||
self.error = false;
|
||
el.setAttribute('data-loaded', 'true');
|
||
el.classList.add('is-loaded');
|
||
|
||
if (self._observer) {
|
||
self._observer.disconnect();
|
||
self._observer = null;
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
if (err.name === 'AbortError') return;
|
||
self.error = true;
|
||
});
|
||
},
|
||
|
||
destroy() {
|
||
if (this._timer) clearTimeout(this._timer);
|
||
if (this._controller) this._controller.abort();
|
||
if (this._observer) this._observer.disconnect();
|
||
}
|
||
};
|
||
}
|