Files
fuwari/src/layouts/Layout.astro
二叉树树 6e23439359 fix #159 (布局): 修复页面滚动控制元素引用刷新问题
在页面切换后,滚动控制相关的DOM元素引用未及时更新,导致滚动功能失效。添加refreshControlRefs函数在每次滚动检查前刷新引用,并确保页面加载完成后立即执行一次滚动检查。
2026-01-27 23:28:53 +08:00

766 lines
25 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import { profileConfig, siteConfig } from "@/config";
import ConfigCarrier from "@components/ConfigCarrier.astro";
import NewPostNotification from "@components/widget/NewPostNotification.astro";
import {
AUTO_MODE,
BANNER_HEIGHT,
BANNER_HEIGHT_EXTEND,
BANNER_HEIGHT_HOME,
DARK_MODE,
DEFAULT_THEME,
LIGHT_MODE,
PAGE_WIDTH,
} from "../constants/constants";
import { defaultFavicons } from "../constants/icon";
import type { Favicon } from "../types/config";
import { url, pathsEqual } from "../utils/url-utils";
import "katex/dist/katex.css";
interface Props {
title?: string;
banner?: string;
description?: string;
lang?: string;
setOGTypeArticle?: boolean;
}
let { title, banner, description, lang, setOGTypeArticle } = Astro.props;
// apply a class to the body element to decide the height of the banner, only used for initial page load
// Swup can update the body for each page visit, but it's after the page transition, causing a delay for banner height change
// so use Swup hooks instead to change the height immediately when a link is clicked
const isHomePage = pathsEqual(Astro.url.pathname, url("/"));
// defines global css variables
// why doing this in Layout instead of GlobalStyles: https://github.com/withastro/astro/issues/6728#issuecomment-1502203757
const configHue = siteConfig.themeColor.hue;
if (!banner || typeof banner !== "string" || banner.trim() === "") {
banner = siteConfig.banner.src;
}
// TODO don't use post cover as banner for now
banner = siteConfig.banner.src;
const enableBanner = siteConfig.banner.enable;
let pageTitle: string;
if (title) {
pageTitle = `${title} - ${siteConfig.title}`;
} else {
if (siteConfig.subtitle && siteConfig.subtitle.trim() !== "") {
pageTitle = `${siteConfig.title} - ${siteConfig.subtitle}`;
} else {
pageTitle = siteConfig.title;
}
}
// Use siteConfig.description as fallback for meta description when no description is provided
if (!description) {
description = siteConfig.description || pageTitle;
}
const favicons: Favicon[] =
siteConfig.favicon.length > 0 ? siteConfig.favicon : defaultFavicons;
// const siteLang = siteConfig.lang.replace('_', '-')
if (!lang) {
lang = `${siteConfig.lang}`;
}
const siteLang = lang.replace("_", "-");
const bannerOffsetByPosition = {
top: `${BANNER_HEIGHT_EXTEND}vh`,
center: `${BANNER_HEIGHT_EXTEND / 2}vh`,
bottom: "0",
};
const bannerOffset =
bannerOffsetByPosition[siteConfig.banner.position || "center"];
---
<!DOCTYPE html>
<html lang={siteLang} class="bg-[var(--page-bg)] transition text-[12px] md:text-[16px] dark"
data-overlayscrollbars-initialize> <!-- 手机端适配 -->
<head>
<title>{pageTitle}</title>
<meta charset="UTF-8" />
<meta name="description" content={description}>
<meta name="author" content={profileConfig.name}>
{siteConfig.keywords && (
<meta name="keywords" content={siteConfig.keywords.join(", ")}>
)}
<meta property="og:site_name" content={siteConfig.title}>
<meta property="og:url" content={Astro.url}>
<meta property="og:title" content={pageTitle}>
<meta property="og:description" content={description}>
{setOGTypeArticle ? (
<meta property="og:type" content="article" />
) : (
<meta property="og:type" content="website" />
)}
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:url" content={Astro.url}>
<meta name="twitter:title" content={pageTitle}>
<meta name="twitter:description" content={description}>
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<link rel="preconnect" href="https://pic1.acofork.com" />
<link rel="preconnect" href="https://umami.acofork.com" />
<link rel="preconnect" href="https://support.nodeget.com" />
<script src="/js/umami-share.js" defer></script>
<script data-swup-ignore-script src="https://pic1.acofork.com/random.js" defer></script>
{/* <!-- Content Security Policy -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://giscus.app https://hpic.072103.xyz https://umami.2x.nz https://hm.baidu.com https://www.googletagmanager.com https://www.google-analytics.com https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net https://static.cloudflareinsights.com https://*.adtrafficquality.google; style-src 'self' 'unsafe-inline' https://giscus.app https://fonts.googleapis.com https://api.iconify.design; font-src 'self' https://fonts.gstatic.com https://api.iconify.design; img-src 'self' data: https: http:; connect-src 'self' https://umami.2x.nz https://hm.baidu.com https://www.google-analytics.com https://analytics.google.com https://api.iconify.design https://static.cloudflareinsights.com https://pic.2x.nz https://q2.qlogo.cn https://ep1.adtrafficquality.google https://googleads.g.doubleclick.net; frame-src 'self' https://giscus.app *.bilibili.com https://www.google.com https://googleads.g.doubleclick.net https://support.nodeget.com https://*.adtrafficquality.google; object-src 'none'; base-uri 'self'; form-action 'self';"> */}
{favicons.map(favicon => (
<link rel="icon"
href={favicon.src.startsWith('/') ? url(favicon.src) : favicon.src}
sizes={favicon.sizes}
media={favicon.theme && `(prefers-color-scheme: ${favicon.theme})`}
/>
))}
<!-- Set the theme before the page is rendered to avoid a flash -->
<script is:inline define:vars={{DEFAULT_THEME, LIGHT_MODE, DARK_MODE, AUTO_MODE, BANNER_HEIGHT_EXTEND, PAGE_WIDTH, configHue, forceDarkMode: siteConfig.themeColor.forceDarkMode}}>
// Force dark mode if configured
if (forceDarkMode) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', DARK_MODE);
} else {
// Load the theme from local storage
const theme = localStorage.getItem('theme') || DARK_MODE;
switch (theme) {
case LIGHT_MODE:
document.documentElement.classList.remove('dark');
break
case DARK_MODE:
document.documentElement.classList.add('dark');
break
case AUTO_MODE:
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
}
// Load the hue from local storage
const hue = localStorage.getItem('hue') || configHue;
document.documentElement.style.setProperty('--hue', hue);
// calculate the --banner-height-extend, which needs to be a multiple of 4 to avoid blurry text
let offset = Math.floor(window.innerHeight * (BANNER_HEIGHT_EXTEND / 100));
offset = offset - offset % 4;
document.documentElement.style.setProperty('--banner-height-extend', `${offset}px`);
</script>
<style define:vars={{ configHue }}>
/* Fallback for no-js: Apply default theme hue */
:root {
--hue: var(--configHue);
}
</style>
<style define:vars={{
configHue,
'page-width': `${PAGE_WIDTH}rem`,
'bg-url': siteConfig.background.src ? `url(${siteConfig.background.src})` : 'none',
'bg-enable': siteConfig.background.enable ? '1' : '0',
'bg-position': siteConfig.background.position || 'center',
'bg-size': siteConfig.background.size || 'cover',
'bg-repeat': siteConfig.background.repeat || 'no-repeat',
'bg-attachment': siteConfig.background.attachment || 'fixed',
'bg-opacity': (siteConfig.background.opacity || 0.3).toString()
}}>
:root {
--bg-url: var(--bg-url);
--bg-enable: var(--bg-enable);
--bg-position: var(--bg-position);
--bg-size: var(--bg-size);
--bg-repeat: var(--bg-repeat);
--bg-attachment: var(--bg-attachment);
--bg-opacity: var(--bg-opacity);
}
/* Background image configuration */
body {
--bg-url: var(--bg-url);
--bg-enable: var(--bg-enable);
--bg-position: var(--bg-position);
--bg-size: var(--bg-size);
--bg-repeat: var(--bg-repeat);
--bg-attachment: var(--bg-attachment);
--bg-opacity: var(--bg-opacity);
}
#bg-box {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-position: var(--bg-position);
background-size: var(--bg-size);
background-repeat: var(--bg-repeat);
opacity: 0;
pointer-events: none;
z-index: -1;
transition: opacity 0.3s ease-in-out;
/* Prevent background jump on mobile browsers with dynamic URL bars */
will-change: transform;
transform: translateZ(0);
}
#bg-box.loaded {
opacity: calc(var(--bg-opacity) * var(--bg-enable));
}
</style>
<slot name="head"></slot>
<link rel="alternate" type="application/rss+xml" title={profileConfig.name} href={`${Astro.site}rss.xml`}/>
<!-- Umami分析自建 -->
<script defer src="https://umami.acofork.com/script.js" data-website-id="5d710dbd-3a2e-43e3-a553-97b415090c63" data-swup-ignore-script></script>
</head>
<body class=" min-h-screen transition " class:list={[{"lg:is-home": isHomePage, "enable-banner": enableBanner}]}
data-overlayscrollbars-initialize
>
<noscript>
<style>
#bg-box {
opacity: 1 !important;
}
</style>
</noscript>
<div id="bg-box"></div>
<ConfigCarrier></ConfigCarrier>
<NewPostNotification></NewPostNotification>
<slot />
<!-- increase the page height during page transition to prevent the scrolling animation from jumping -->
<div id="page-height-extend" class="hidden h-[300vh]"></div>
</body>
</html>
<style is:global define:vars={{
bannerOffset,
'banner-height-home': `${BANNER_HEIGHT_HOME}vh`,
'banner-height': `${BANNER_HEIGHT}vh`,
}}>
@tailwind components;
@layer components {
.enable-banner.is-home #banner-wrapper {
@apply h-[var(--banner-height-home)] translate-y-[var(--banner-height-extend)]
}
.enable-banner #banner-wrapper {
@apply h-[var(--banner-height-home)]
}
.enable-banner.is-home #banner {
@apply h-[var(--banner-height-home)] translate-y-0
}
.enable-banner #banner {
@apply h-[var(--banner-height-home)] translate-y-[var(--bannerOffset)]
}
.enable-banner.is-home #main-grid {
@apply translate-y-[var(--banner-height-extend)];
}
.enable-banner #top-row {
@apply h-[calc(var(--banner-height-home)_-_4.5rem)] transition-all duration-300
}
.enable-banner.is-home #sidebar-sticky {
@apply top-[calc(1rem_-_var(--banner-height-extend))]
}
.navbar-hidden {
@apply opacity-0 -translate-y-16
}
}
</style>
<script>
import 'overlayscrollbars/overlayscrollbars.css';
import {
OverlayScrollbars,
// ScrollbarsHidingPlugin,
// SizeObserverPlugin,
// ClickScrollPlugin
} from 'overlayscrollbars';
import {getHue, getStoredTheme, setHue, setTheme, getBgBlur, setBgBlur, getHideBg, setHideBg} from "../utils/setting-utils";
import {pathsEqual, url} from "../utils/url-utils";
import {
BANNER_HEIGHT,
BANNER_HEIGHT_HOME,
BANNER_HEIGHT_EXTEND,
MAIN_PANEL_OVERLAPS_BANNER_HEIGHT
} from "../constants/constants";
import { siteConfig } from '../config';
/* Preload fonts */
// (async function() {
// try {
// await Promise.all([
// document.fonts.load("400 1em Roboto"),
// document.fonts.load("700 1em Roboto"),
// ]);
// document.body.classList.remove("hidden");
// } catch (error) {
// console.log("Failed to load fonts:", error);
// }
// })();
/* TODO This is a temporary solution for style flicker issue when the transition is activated */
/* issue link: https://github.com/withastro/astro/issues/8711, the solution get from here too */
/* update: fixed in Astro 3.2.4 */
/*
function disableAnimation() {
const css = document.createElement('style')
css.appendChild(
document.createTextNode(
`*{
-webkit-transition:none!important;
-moz-transition:none!important;
-o-transition:none!important;
-ms-transition:none!important;
transition:none!important
}`
)
)
document.head.appendChild(css)
return () => {
// Force restyle
;(() => window.getComputedStyle(document.body))()
// Wait for next tick before removing
setTimeout(() => {
document.head.removeChild(css)
}, 1)
}
}
*/
const bannerEnabled = !!document.getElementById('banner-wrapper')
function setClickOutsideToClose(panel: string, ignores: string[]) {
document.addEventListener("click", event => {
let panelDom = document.getElementById(panel);
let tDom = event.target;
if (!(tDom instanceof Node)) return; // Ensure the event target is an HTML Node
for (let ig of ignores) {
let ie = document.getElementById(ig)
if (ie == tDom || (ie?.contains(tDom))) {
return;
}
}
panelDom!.classList.add("float-panel-closed");
});
}
setClickOutsideToClose("display-setting", ["display-setting", "display-settings-switch"])
setClickOutsideToClose("nav-menu-panel", ["nav-menu-panel", "nav-menu-switch"])
setClickOutsideToClose("search-panel", ["search-panel", "search-bar", "search-switch"])
function loadTheme() {
const theme = getStoredTheme()
setTheme(theme)
}
function loadHue() {
setHue(getHue())
}
function loadBgBlur() {
setBgBlur(getBgBlur())
setHideBg(getHideBg())
}
function initCustomScrollbar() {
const bodyElement = document.querySelector('body');
if (!bodyElement) return;
OverlayScrollbars(
// docs say that a initialization to the body element would affect native functionality like window.scrollTo
// but just leave it here for now
{
target: bodyElement,
cancel: {
nativeScrollbarsOverlaid: true, // don't initialize the overlay scrollbar if there is a native one
}
}, {
scrollbars: {
theme: 'scrollbar-base scrollbar-auto py-1',
autoHide: 'move',
autoHideDelay: 500,
autoHideSuspend: false,
},
});
const katexElements = document.querySelectorAll('.katex-display') as NodeListOf<HTMLElement>;
katexElements.forEach((ele) => {
OverlayScrollbars(ele, {
scrollbars: {
theme: 'scrollbar-base scrollbar-auto py-1',
}
});
});
}
function showBanner() {
if (!siteConfig.banner.enable) return;
const banner = document.getElementById('banner');
if (!banner) {
console.error('Banner element not found');
return;
}
banner.classList.remove('opacity-0', 'scale-105');
}
function init() {
// disableAnimation()() // TODO
loadTheme();
loadHue();
loadBgBlur();
initCustomScrollbar();
showBanner();
}
/* Load settings when entering the site */
init();
const setup = () => {
// TODO: temp solution to change the height of the banner
/*
window.swup.hooks.on('animation:out:start', () => {
const path = window.location.pathname
const body = document.querySelector('body')
if (path[path.length - 1] === '/' && !body.classList.contains('is-home')) {
body.classList.add('is-home')
} else if (path[path.length - 1] !== '/' && body.classList.contains('is-home')) {
body.classList.remove('is-home')
}
})
*/
window.swup.hooks.on('link:click', () => {
// Remove the delay for the first time page load
document.documentElement.style.setProperty('--content-delay', '0ms')
// prevent elements from overlapping the navbar
if (!bannerEnabled) {
return
}
let threshold = window.innerHeight * (BANNER_HEIGHT / 100) - 72 - 16
let navbar = document.getElementById('navbar-wrapper')
if (!navbar || !document.body.classList.contains('lg:is-home')) {
return
}
if (document.body.scrollTop >= threshold || document.documentElement.scrollTop >= threshold) {
navbar.classList.add('navbar-hidden')
}
})
window.swup.hooks.on('content:replace', initCustomScrollbar)
window.swup.hooks.on('visit:start', (visit: {to: {url: string}}) => {
// change banner height immediately when a link is clicked
const bodyElement = document.querySelector('body')
if (pathsEqual(visit.to.url, url('/'))) {
bodyElement!.classList.add('lg:is-home');
} else {
bodyElement!.classList.remove('lg:is-home');
}
// increase the page height during page transition to prevent the scrolling animation from jumping
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.remove('hidden')
}
// Hide the TOC while scrolling back to top
let toc = document.getElementById('toc-wrapper');
if (toc) {
toc.classList.add('toc-not-ready')
}
});
window.swup.hooks.on('page:view', () => {
// hide the temp high element when the transition is done
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.remove('hidden')
}
scrollFunction()
});
window.swup.hooks.on('visit:end', (_visit: {to: {url: string}}) => {
setTimeout(() => {
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.add('hidden')
}
// Just make the transition looks better
const toc = document.getElementById('toc-wrapper');
if (toc) {
toc.classList.remove('toc-not-ready')
}
}, 200)
});
}
if (window?.swup?.hooks) {
setup()
} else {
document.addEventListener('swup:enable', setup)
}
let backToTopBtn = document.getElementById('back-to-top-btn');
let goToCommentsBtn = document.getElementById('go-to-comments-btn');
let toc = document.getElementById('toc-wrapper');
let navbar = document.getElementById('navbar-wrapper')
function refreshControlRefs() {
backToTopBtn = document.getElementById('back-to-top-btn');
goToCommentsBtn = document.getElementById('go-to-comments-btn');
toc = document.getElementById('toc-wrapper');
navbar = document.getElementById('navbar-wrapper')
}
function scrollFunction() {
refreshControlRefs()
let bannerHeight = window.innerHeight * (BANNER_HEIGHT / 100)
if (backToTopBtn) {
if (document.body.scrollTop > bannerHeight || document.documentElement.scrollTop > bannerHeight) {
backToTopBtn.classList.remove('hide')
} else {
backToTopBtn.classList.add('hide')
}
}
if (goToCommentsBtn) {
// Only show if comments exist and scrolled down
const commentsExist = !!document.getElementById('giscus-container');
if (commentsExist) {
goToCommentsBtn.classList.remove('hide')
} else {
goToCommentsBtn.classList.add('hide')
}
}
if (bannerEnabled && toc) {
if (document.body.scrollTop > bannerHeight || document.documentElement.scrollTop > bannerHeight) {
toc.classList.remove('toc-hide')
} else {
toc.classList.add('toc-hide')
}
}
if (!bannerEnabled) return
if (navbar) {
const NAVBAR_HEIGHT = 72
const MAIN_PANEL_EXCESS_HEIGHT = MAIN_PANEL_OVERLAPS_BANNER_HEIGHT * 16 // The height the main panel overlaps the banner
let bannerHeight = BANNER_HEIGHT
if (document.body.classList.contains('lg:is-home') && window.innerWidth >= 1024) {
bannerHeight = BANNER_HEIGHT_HOME
}
let threshold = window.innerHeight * (bannerHeight / 100) - NAVBAR_HEIGHT - MAIN_PANEL_EXCESS_HEIGHT - 16
if (document.body.scrollTop >= threshold || document.documentElement.scrollTop >= threshold) {
navbar.classList.add('navbar-hidden')
} else {
navbar.classList.remove('navbar-hidden')
}
}
}
window.onscroll = scrollFunction
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scrollFunction)
} else {
scrollFunction()
}
window.onresize = () => {
// calculate the --banner-height-extend, which needs to be a multiple of 4 to avoid blurry text
let offset = Math.floor(window.innerHeight * (BANNER_HEIGHT_EXTEND / 100));
offset = offset - offset % 4;
document.documentElement.style.setProperty('--banner-height-extend', `${offset}px`);
}
</script>
<script>
import { Fancybox } from "@fancyapps/ui"
import "@fancyapps/ui/dist/fancybox/fancybox.css"
const setup = () => {
Fancybox.bind(".custom-md img, #post-cover img", {
wheel: 'zoom',
clickContent: 'close',
dblclickContent: 'zoom',
click: 'close',
dblclick: 'zoom',
Panels: {
display: ['counter', 'zoom']
},
Images: {
panning: true,
zoom: true,
protect: false
}
})
window.swup.hooks.on("page:view", () => {
Fancybox.bind(".custom-md img, #post-cover img", {
wheel: 'zoom',
clickContent: 'close',
dblclickContent: 'zoom',
click: 'close',
dblclick: 'zoom',
Panels: {
display: ['counter', 'zoom']
},
Images: {
panning: true,
zoom: true,
protect: false
}
})
})
window.swup.hooks.on(
"content:replace",
() => {
Fancybox.unbind(".custom-md img, #post-cover img")
},
{ before: true },
)
}
if (window.swup) {
setup()
} else {
document.addEventListener("swup:enable", setup)
}
</script>
<script is:inline>
// Giscus Loader
function loadGiscus() {
console.log('[Giscus] Attempting to load Giscus...');
const container = document.getElementById('giscus-container');
if (!container) {
console.log('[Giscus] No container found, skipping.');
return;
}
// Prevent double loading if script is already there
if (container.querySelector('script[src*="giscus"]')) {
console.log('[Giscus] Script already exists, skipping.');
return;
}
console.log('[Giscus] Container found, creating script...');
const script = document.createElement('script');
script.src = "https://giscus.app/client.js";
script.async = true;
script.crossOrigin = "anonymous";
// Required attributes
const requiredAttrs = [
'repo', 'repoId', 'category', 'categoryId',
'mapping', 'strict', 'reactionsEnabled',
'emitMetadata', 'inputPosition', 'lang', 'loading'
];
requiredAttrs.forEach(attr => {
if (container.dataset[attr]) {
const kebab = attr.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
script.setAttribute(`data-${kebab}`, container.dataset[attr]);
}
});
// Theme handling
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
console.log(`[Giscus] Setting initial theme to: ${theme}`);
script.setAttribute('data-theme', theme);
container.appendChild(script);
console.log('[Giscus] Script appended to container.');
}
function initGiscus() {
console.log('[Giscus] initGiscus called.');
loadGiscus();
// Listen for Swup navigation
// Use a polling mechanism to wait for swup if it's not immediately available
const checkSwup = setInterval(() => {
if (window.swup) {
console.log('[Giscus] Swup detected via polling, registering hooks.');
clearInterval(checkSwup);
window.swup.hooks.on('page:view', () => {
console.log('[Giscus] Swup page:view triggered.');
loadGiscus();
});
// Also listen for content:replace to be safe, as page:view might sometimes fire before content is fully swapped in edge cases
window.swup.hooks.on('content:replace', () => {
console.log('[Giscus] Swup content:replace triggered.');
// Small delay to ensure DOM is ready
setTimeout(loadGiscus, 0);
});
} else {
console.log('[Giscus] Waiting for Swup...');
}
}, 200);
// Stop polling after 5 seconds to prevent infinite loop
setTimeout(() => clearInterval(checkSwup), 5000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGiscus);
} else {
initGiscus();
}
// Global theme observer
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const iframe = document.querySelector('iframe.giscus-frame');
if (iframe) {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
console.log(`[Giscus] Theme changed to: ${theme}, updating iframe.`);
iframe.contentWindow.postMessage({
giscus: { setConfig: { theme: theme } }
}, 'https://giscus.app');
}
}
});
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
</script>
<svg style="position: absolute; width: 0; height: 0; overflow: hidden;" aria-hidden="true">
<defs>
<filter id="liquid-glass">
<feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="2" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="20" xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
</svg>