diff --git a/hypn0/hypn0_site/models.py b/hypn0/hypn0_site/models.py index af791de..c4ea5bf 100644 --- a/hypn0/hypn0_site/models.py +++ b/hypn0/hypn0_site/models.py @@ -200,6 +200,19 @@ class TbHypn0Item(models.Model): dark_bg = f"rgb({max(9, int(9 + r * 0.07))}, {max(9, int(9 + g * 0.07))}, {max(13, int(13 + b * 0.08))})" return f"--card-bg-light: {light_bg}; --card-bg-dark: {dark_bg};" + @property + def card_svg(self) -> str: + """ + Возвращает SVG-код картины для изолированного рендеринга в карточке галереи. + """ + if not self.file_svg: + return "" + try: + with self.file_svg.open("r") as f: + return f.read() + except Exception: + return "" + def increment_views(self): """Безопасный инкремент просмотров""" TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1) diff --git a/hypn0/hypn0_site/services/halftone.py b/hypn0/hypn0_site/services/halftone.py index 93e7269..a214532 100644 --- a/hypn0/hypn0_site/services/halftone.py +++ b/hypn0/hypn0_site/services/halftone.py @@ -229,7 +229,11 @@ def generate_halftone_svg( fill_style = f"fill:{clean_color};stroke:{clean_color};opacity:{opacity:.2f};" if is_animated: - anim_rule = f"animation:noise {duration:.2f}s ease-in-out infinite alternate;animation-delay:var(--d);" + anim_rule = ( + f"animation:noise {duration:.2f}s ease-in-out infinite alternate;" + f"animation-delay:var(--d);" + f"animation-play-state:var(--hypn0-play,running);" + ) keyframes_rule = ( f"@keyframes noise{{" f"0%{{opacity:{max(0.2, opacity * 0.7):.2f};transform:scale(1) rotate(0deg)}}" @@ -266,17 +270,19 @@ def generate_halftone_svg( def prepare_gallery_svg(svg_content: str) -> str: """ Модифицирует SVG для долговременного хранения в галерее: - добавляет CSS-правила, чтобы в изолированном контексте () анимация - находилась на паузе по умолчанию и запускалась только при наведении (:hover), - предотвращая перегрузку CPU/GPU браузера при выводе сетки карточек. + добавляет CSS Custom Property (--hypn0-play: paused), которое наследуется + внутрь элементов и теневых деревьев . В изолированном контексте (Shadow DOM или ) + анимация заморожена по умолчанию (0% нагрузки на CPU), а при наведении (:hover или :host(:hover)) + запускается. """ if not svg_content: return svg_content - # Надежная глобальная пауза в контексте :root и пробуждение по ховеру + # Наследуемая переменная паузы и оверрайд стилей фигур hover_css = ( - "@media(hover:hover){:root{animation-play-state:paused!important}:root:hover *{animation-play-state:running!important}}" - "svg:not(:hover) *{animation-play-state:paused!important}" + "svg{--hypn0-play:paused}" + ":host(:hover) svg,svg:hover{--hypn0-play:running!important}" + ".shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}" ) if "" in svg_content: @@ -293,10 +299,24 @@ def prepare_active_svg(svg_content: str) -> str: if not svg_content: return svg_content - # Удаляем любые внедренные правила пауз и медиа-запросов ховера - cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", svg_content) + # Удаляем внедренные правила паузы через CSS-переменные и старые форматы + gallery_css_variants = [ + "svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", + "svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", + ] + cleaned = svg_content + for gcss in gallery_css_variants: + cleaned = cleaned.replace(gcss, "") + + cleaned = re.sub(r"svg\s*\{[^}]*--hypn0-play:\s*paused[^}]*\}", "", cleaned) + cleaned = re.sub(r"(:host\(:hover\)\s*svg\s*,\s*)?svg:hover\s*\{[^}]*--hypn0-play:[^}]*\}", "", cleaned) + cleaned = re.sub( + r"\.shape,circle,rect,polygon,path\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}", + "", + cleaned, + ) + cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", cleaned) cleaned = re.sub(r"svg:not\(:hover\)[^{]*\{[^}]*\}", "", cleaned) - # Старый формат для обратной совместимости old_hover_css = ( "svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect," "svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}" diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index aaefe42..f4c7272 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -238,9 +238,9 @@ class GalleryPreparationTests(TestCase): def test_prepare_gallery_svg(self): raw_svg = '' prepared = prepare_gallery_svg(raw_svg) - self.assertIn("@media(hover:hover)", prepared) - self.assertIn(":root{animation-play-state:paused!important}", prepared) - self.assertIn("svg:not(:hover)", prepared) + self.assertIn("svg{--hypn0-play:paused}", prepared) + self.assertIn(":host(:hover) svg,svg:hover{--hypn0-play:running!important}", prepared) + self.assertIn("animation-play-state:var(--hypn0-play,paused)!important", prepared) class PublishViewTests(TestCase): @@ -322,6 +322,23 @@ class SvgAnalysisAndActiveSvgTests(TestCase): active = prepare_active_svg(gallery_svg) self.assertNotIn("animation-play-state:paused!important", active) + def test_prepare_active_svg_preserves_color_and_animation(self): + gallery_svg = ( + '' + '' + ) + active = prepare_active_svg(gallery_svg) + self.assertIn("fill:#e6b400", active) + self.assertIn("stroke:#e6b400", active) + self.assertIn("animation:noise 1.36s", active) + self.assertNotIn("svg{--hypn0-play:paused}", active) + self.assertNotIn("animation-play-state:var(--hypn0-play,paused)!important", active) + def test_analyze_svg_structure(self): svg = ( '' @@ -560,7 +577,7 @@ class UnconsciousMatrixTests(TestCase): hashes2 = [n["s_hash_id"] for n in m2] self.assertEqual(hashes1, hashes2) - def test_gallery_detail_renders_matrix(self): + def test_gallery_detail_renders_matrix_and_card_bg_style(self): item = self.items[0] response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": item.s_hash_id})) self.assertEqual(response.status_code, 200) @@ -569,6 +586,9 @@ class UnconsciousMatrixTests(TestCase): self.assertContains(response, f"#{item.s_hash_id}") self.assertContains(response, "btn-nav-prev") self.assertContains(response, "btn-nav-next") + self.assertContains(response, "--card-bg-light") + self.assertContains(response, "--card-bg-dark") + self.assertContains(response, "bg-[var(--card-bg-light)]") class GalleryFreshFloorTests(TestCase): @@ -729,3 +749,18 @@ class CardBgStyleTests(TestCase): item_invalid = TbHypn0Item(j_metadata={"color": "invalid-hex"}) self.assertIn("--card-bg-light:", item_invalid.card_bg_style) self.assertIn("--card-bg-dark:", item_invalid.card_bg_style) + + def test_card_svg_property_and_shadow_dom_rendering(self): + item = TbHypn0Item( + s_title="Тестовый SVG", + file_svg=ContentFile(b'', name="test_card.svg"), + j_metadata={"color": "#10b981"}, + is_public=True, + ) + item.save(visitor_uuid_or_fp="123e4567-e89b-12d3-a456-426614174000") + self.assertIn('id="test-svg"', item.card_svg) + + response = self.client.get(reverse("hypn0_site:index")) + self.assertContains(response, 'template shadowrootmode="open"') + self.assertContains(response, "hypn0-card-svg") + self.assertContains(response, "--hypn0-play: running !important") diff --git a/hypn0/templates/_base.html b/hypn0/templates/_base.html index 2a69700..e61c8a0 100644 --- a/hypn0/templates/_base.html +++ b/hypn0/templates/_base.html @@ -121,6 +121,19 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume evt.detail.headers['X-CSRFToken'] = token; } }); + + 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); }); + document.addEventListener('htmx:afterSwap', function(evt) { attachShadowRoots(evt.detail.target); }); {% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %} diff --git a/hypn0/templates/block/gallery_card.html b/hypn0/templates/block/gallery_card.html index acee653..d1f8ddc 100644 --- a/hypn0/templates/block/gallery_card.html +++ b/hypn0/templates/block/gallery_card.html @@ -7,10 +7,29 @@ - {{ item.s_title }} +
+ +
diff --git a/hypn0/templates/gallery/detail.html b/hypn0/templates/gallery/detail.html index 5ddb43c..34dbcd8 100644 --- a/hypn0/templates/gallery/detail.html +++ b/hypn0/templates/gallery/detail.html @@ -76,7 +76,8 @@ -
+
+ + +
+ + + + + К генератору и витрине + + + + Фаза {{ page_obj.number }} из {{ page_obj.paginator.num_pages }} (Всего: {{ page_obj.paginator.count }}) + +
+ + +
+
+

+ {{ floor_title }} +

+ + {{ floor_badge }} + +
+

+ {{ floor_subtitle }} +

+
+ + +
+ {% for item in page_obj %} + {% include "block/gallery_card.html" %} + {% empty %} +
+ + + +

В этой зоне психо-матрицы пока нет зафиксированных картин.

+
+ {% endfor %} +
+ + + {% if page_obj.has_other_pages %} +
+
+ {% if page_obj.has_previous %} + + + + + Предыдущая фаза + + {% else %} + + + + + Предыдущая фаза + + {% endif %} +
+ + + + +
+ {% if page_obj.has_next %} + + Следующая фаза + + + + + {% else %} + + Следующая фаза + + + + + {% endif %} +
+
+ {% endif %} + +
+{% endblock CONTENT %}