|
|
|
|
@@ -1,121 +1,474 @@
|
|
|
|
|
let globalSvgDefs = null;
|
|
|
|
|
class GlassSurface {
|
|
|
|
|
constructor(element, config = {}) {
|
|
|
|
|
this.el = element;
|
|
|
|
|
this.config = Object.assign({
|
|
|
|
|
width: 200,
|
|
|
|
|
height: 20,
|
|
|
|
|
borderRadius: 20,
|
|
|
|
|
borderWidth: 0.07,
|
|
|
|
|
brightness: 50,
|
|
|
|
|
opacity: 0,
|
|
|
|
|
blur: 11,
|
|
|
|
|
displace: 0,
|
|
|
|
|
backgroundOpacity: 0,
|
|
|
|
|
saturation: 1,
|
|
|
|
|
distortionScale: -180,
|
|
|
|
|
redOffset: 0,
|
|
|
|
|
greenOffset: 0,
|
|
|
|
|
blueOffset: 0,
|
|
|
|
|
xChannel: 'R',
|
|
|
|
|
yChannel: 'G',
|
|
|
|
|
mixBlendMode: 'difference',
|
|
|
|
|
className: '',
|
|
|
|
|
style: {}
|
|
|
|
|
}, config);
|
|
|
|
|
|
|
|
|
|
function ensureGlobalDefs() {
|
|
|
|
|
if (!globalSvgDefs) {
|
|
|
|
|
globalSvgDefs = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
|
|
|
globalSvgDefs.setAttribute('id', 'glass-surface-defs');
|
|
|
|
|
globalSvgDefs.setAttribute('aria-hidden', 'true');
|
|
|
|
|
globalSvgDefs.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;z-index:-9999;opacity:0;';
|
|
|
|
|
document.body.appendChild(globalSvgDefs);
|
|
|
|
|
this.uniqueId = Math.random().toString(36).substring(2, 9).replace(/:/g, '-');
|
|
|
|
|
this.filterId = `glass-filter-${this.uniqueId}`;
|
|
|
|
|
this.redGradId = `red-grad-${this.uniqueId}`;
|
|
|
|
|
this.blueGradId = `blue-grad-${this.uniqueId}`;
|
|
|
|
|
|
|
|
|
|
this.isMounted = false;
|
|
|
|
|
this.liquidGlassEnabled = true;
|
|
|
|
|
|
|
|
|
|
this.init();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
supportsSVGFilters() {
|
|
|
|
|
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
|
|
|
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
|
|
|
if (ua.includes('firefox')) return false;
|
|
|
|
|
const isIOS = /iphone|ipad|ipod/.test(ua);
|
|
|
|
|
const isSafari = ua.includes('safari') && !ua.includes('chrome');
|
|
|
|
|
const isWebkitOnly = ua.includes('webkit') && !ua.includes('chrome');
|
|
|
|
|
if (isIOS || isSafari || isWebkitOnly) return false;
|
|
|
|
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.style.backdropFilter = `url(#${this.filterId})`;
|
|
|
|
|
const supported = div.style.backdropFilter !== '';
|
|
|
|
|
if (!supported) {
|
|
|
|
|
div.style.cssText = `-webkit-backdrop-filter: url(#${this.filterId})`;
|
|
|
|
|
return div.style.webkitBackdropFilter !== '' && div.style.webkitBackdropFilter !== undefined;
|
|
|
|
|
}
|
|
|
|
|
return supported;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
supportsBackdropFilter() {
|
|
|
|
|
return CSS.supports('backdrop-filter', 'blur(10px)');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getIsDark() {
|
|
|
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
this.isMounted = true;
|
|
|
|
|
this.isDark = this.getIsDark();
|
|
|
|
|
this.svgSupported = this.supportsSVGFilters();
|
|
|
|
|
this.backdropSupported = this.supportsBackdropFilter();
|
|
|
|
|
|
|
|
|
|
this.darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
|
|
|
this.darkModeListener = (e) => {
|
|
|
|
|
this.isDark = e.matches;
|
|
|
|
|
this.updateStyles();
|
|
|
|
|
};
|
|
|
|
|
this.darkModeQuery.addEventListener('change', this.darkModeListener);
|
|
|
|
|
|
|
|
|
|
this.checkSettings();
|
|
|
|
|
|
|
|
|
|
this.el.classList.add('glass-surface');
|
|
|
|
|
this.el.style.position = 'relative';
|
|
|
|
|
this.el.style.transform = 'translateZ(0)';
|
|
|
|
|
this.el.style.backfaceVisibility = 'hidden';
|
|
|
|
|
this.el.style.perspective = '1000';
|
|
|
|
|
this.el.style.contain = 'layout style paint';
|
|
|
|
|
|
|
|
|
|
this.applyBaseStyles();
|
|
|
|
|
|
|
|
|
|
this.createContentWrapper();
|
|
|
|
|
|
|
|
|
|
if (this.liquidGlassEnabled) {
|
|
|
|
|
this.applyGlassEffect();
|
|
|
|
|
} else {
|
|
|
|
|
this.applyFallback();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setupResizeObserver();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
checkSettings() {
|
|
|
|
|
try {
|
|
|
|
|
const settings = localStorage.getItem('glassSettings');
|
|
|
|
|
if (settings) {
|
|
|
|
|
const parsed = JSON.parse(settings);
|
|
|
|
|
this.liquidGlassEnabled = parsed.liquidGlassEnabled !== false;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Failed to read glass settings:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyBaseStyles() {
|
|
|
|
|
const width = typeof this.config.width === 'number' ? `${this.config.width}px` : this.config.width;
|
|
|
|
|
const height = typeof this.config.height === 'number' ? `${this.config.height}px` : this.config.height;
|
|
|
|
|
|
|
|
|
|
this.el.style.width = width || 'auto';
|
|
|
|
|
this.el.style.height = height || 'auto';
|
|
|
|
|
this.el.style.borderRadius = `${this.config.borderRadius}px`;
|
|
|
|
|
this.el.style.setProperty('--glass-frost', this.config.backgroundOpacity);
|
|
|
|
|
this.el.style.setProperty('--glass-saturation', this.config.saturation);
|
|
|
|
|
|
|
|
|
|
Object.assign(this.el.style, this.config.style);
|
|
|
|
|
|
|
|
|
|
if (this.config.className) {
|
|
|
|
|
this.el.classList.add(...this.config.className.split(' '));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.isDark) {
|
|
|
|
|
this.el.classList.add('dark-mode');
|
|
|
|
|
} else {
|
|
|
|
|
this.el.classList.add('light-mode');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createContentWrapper() {
|
|
|
|
|
this.contentWrapper = document.createElement('div');
|
|
|
|
|
this.contentWrapper.className = 'glass-surface__content';
|
|
|
|
|
this.contentWrapper.style.cssText = `
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: 4px;
|
|
|
|
|
border-radius: inherit;
|
|
|
|
|
position: relative;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
while (this.el.firstChild) {
|
|
|
|
|
this.contentWrapper.appendChild(this.el.firstChild);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.el.appendChild(this.contentWrapper);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyGlassEffect() {
|
|
|
|
|
if (this.svgSupported && this.backdropSupported) {
|
|
|
|
|
this.applySVGFilter();
|
|
|
|
|
} else if (this.backdropSupported) {
|
|
|
|
|
this.applyBackdropFallback();
|
|
|
|
|
} else {
|
|
|
|
|
this.applyBasicFallback();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applySVGFilter() {
|
|
|
|
|
this.el.style.willChange = 'backdrop-filter, transform';
|
|
|
|
|
|
|
|
|
|
const bgColor = this.isDark
|
|
|
|
|
? `hsl(0 0% 0% / ${this.config.backgroundOpacity})`
|
|
|
|
|
: `hsl(0 0% 100% / ${this.config.backgroundOpacity})`;
|
|
|
|
|
|
|
|
|
|
const shadow = this.isDark
|
|
|
|
|
? '0 0 0 0.5px rgba(255, 255, 255, 0.1) inset, 0px 4px 16px rgba(0, 0, 0, 0.1), 0px 8px 24px rgba(0, 0, 0, 0.08)'
|
|
|
|
|
: '0 0 0 0.5px rgba(255, 255, 255, 0.15) inset, 0px 4px 16px rgba(0, 0, 0, 0.06), 0px 8px 24px rgba(0, 0, 0, 0.04)';
|
|
|
|
|
|
|
|
|
|
this.el.style.background = bgColor;
|
|
|
|
|
this.el.style.backdropFilter = `url(#${this.filterId}) saturate(${this.config.saturation})`;
|
|
|
|
|
this.el.style.webkitBackdropFilter = `url(#${this.filterId}) saturate(${this.config.saturation})`;
|
|
|
|
|
this.el.style.boxShadow = shadow;
|
|
|
|
|
|
|
|
|
|
this.createSVGFilter();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createSVGFilter() {
|
|
|
|
|
this.svgContainer = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
|
|
|
this.svgContainer.setAttribute("class", "glass-surface__filter");
|
|
|
|
|
this.svgContainer.style.cssText = `
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
z-index: -10;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
transform: translateZ(0);
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
this.svgContainer.innerHTML = `
|
|
|
|
|
<defs>
|
|
|
|
|
<filter id="${this.filterId}" color-interpolation-filters="sRGB" x="0%" y="0%" width="100%" height="100%">
|
|
|
|
|
<feImage ref="feImage" x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" result="map" />
|
|
|
|
|
<feDisplacementMap ref="dispRed" in="SourceGraphic" in2="map" id="redchannel" result="dispRed" />
|
|
|
|
|
<feColorMatrix in="dispRed" type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="red" />
|
|
|
|
|
<feDisplacementMap ref="dispGreen" in="SourceGraphic" in2="map" id="greenchannel" result="dispGreen" />
|
|
|
|
|
<feColorMatrix in="dispGreen" type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="green" />
|
|
|
|
|
<feDisplacementMap ref="dispBlue" in="SourceGraphic" in2="map" id="bluechannel" result="dispBlue" />
|
|
|
|
|
<feColorMatrix in="dispBlue" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="blue" />
|
|
|
|
|
<feBlend in="red" in2="green" mode="screen" result="rg" />
|
|
|
|
|
<feBlend in="rg" in2="blue" mode="screen" result="output" />
|
|
|
|
|
<feGaussianBlur ref="gaussianBlur" in="output" stdDeviation="0.7" />
|
|
|
|
|
</filter>
|
|
|
|
|
</defs>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
this.el.appendChild(this.svgContainer);
|
|
|
|
|
|
|
|
|
|
this.feImage = this.svgContainer.querySelector('[ref="feImage"]');
|
|
|
|
|
this.dispRed = this.svgContainer.querySelector('[ref="dispRed"]');
|
|
|
|
|
this.dispGreen = this.svgContainer.querySelector('[ref="dispGreen"]');
|
|
|
|
|
this.dispBlue = this.svgContainer.querySelector('[ref="dispBlue"]');
|
|
|
|
|
this.gaussianBlur = this.svgContainer.querySelector('[ref="gaussianBlur"]');
|
|
|
|
|
|
|
|
|
|
this.updateFilterAttributes();
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(() => this.updateDisplacementMap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateFilterAttributes() {
|
|
|
|
|
if (!this.dispRed || !this.dispGreen || !this.dispBlue || !this.gaussianBlur) return;
|
|
|
|
|
|
|
|
|
|
this.dispRed.setAttribute('scale', (this.config.distortionScale + this.config.redOffset).toString());
|
|
|
|
|
this.dispRed.setAttribute('xChannelSelector', this.config.xChannel);
|
|
|
|
|
this.dispRed.setAttribute('yChannelSelector', this.config.yChannel);
|
|
|
|
|
|
|
|
|
|
this.dispGreen.setAttribute('scale', (this.config.distortionScale + this.config.greenOffset).toString());
|
|
|
|
|
this.dispGreen.setAttribute('xChannelSelector', this.config.xChannel);
|
|
|
|
|
this.dispGreen.setAttribute('yChannelSelector', this.config.yChannel);
|
|
|
|
|
|
|
|
|
|
this.dispBlue.setAttribute('scale', (this.config.distortionScale + this.config.blueOffset).toString());
|
|
|
|
|
this.dispBlue.setAttribute('xChannelSelector', this.config.xChannel);
|
|
|
|
|
this.dispBlue.setAttribute('yChannelSelector', this.config.yChannel);
|
|
|
|
|
|
|
|
|
|
this.gaussianBlur.setAttribute('stdDeviation', this.config.displace.toString());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyBackdropFallback() {
|
|
|
|
|
this.el.style.willChange = 'transform';
|
|
|
|
|
|
|
|
|
|
if (this.isDark) {
|
|
|
|
|
this.el.style.background = 'rgba(0, 0, 0, 0.3)';
|
|
|
|
|
this.el.style.backdropFilter = 'blur(12px) saturate(1.8) brightness(1.1)';
|
|
|
|
|
this.el.style.webkitBackdropFilter = 'blur(12px) saturate(1.8) brightness(1.1)';
|
|
|
|
|
this.el.style.boxShadow = '0 0 0 0.5px rgba(255, 255, 255, 0.1) inset';
|
|
|
|
|
} else {
|
|
|
|
|
this.el.style.background = 'rgba(255, 255, 255, 0.1)';
|
|
|
|
|
this.el.style.backdropFilter = 'blur(12px) saturate(1.8) brightness(1.05)';
|
|
|
|
|
this.el.style.webkitBackdropFilter = 'blur(12px) saturate(1.8) brightness(1.05)';
|
|
|
|
|
this.el.style.boxShadow = '0 0 0 0.5px rgba(255, 255, 255, 0.15) inset, 0px 4px 16px rgba(0, 0, 0, 0.04)';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyBasicFallback() {
|
|
|
|
|
this.el.style.willChange = 'transform';
|
|
|
|
|
|
|
|
|
|
if (this.isDark) {
|
|
|
|
|
this.el.style.background = 'rgba(0, 0, 0, 0.5)';
|
|
|
|
|
this.el.style.boxShadow = '0 0 0 0.5px rgba(255, 255, 255, 0.1) inset';
|
|
|
|
|
} else {
|
|
|
|
|
this.el.style.background = 'rgba(255, 255, 255, 0.2)';
|
|
|
|
|
this.el.style.boxShadow = '0 0 0 0.5px rgba(255, 255, 255, 0.15) inset';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyFallback() {
|
|
|
|
|
if (this.backdropSupported) {
|
|
|
|
|
this.applyBackdropFallback();
|
|
|
|
|
} else {
|
|
|
|
|
this.applyBasicFallback();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateStyles() {
|
|
|
|
|
if (!this.isMounted) return;
|
|
|
|
|
|
|
|
|
|
this.el.classList.remove('dark-mode', 'light-mode');
|
|
|
|
|
if (this.isDark) {
|
|
|
|
|
this.el.classList.add('dark-mode');
|
|
|
|
|
} else {
|
|
|
|
|
this.el.classList.add('light-mode');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.liquidGlassEnabled) {
|
|
|
|
|
if (this.svgSupported && this.backdropSupported) {
|
|
|
|
|
const bgColor = this.isDark
|
|
|
|
|
? `hsl(0 0% 0% / ${this.config.backgroundOpacity})`
|
|
|
|
|
: `hsl(0 0% 100% / ${this.config.backgroundOpacity})`;
|
|
|
|
|
|
|
|
|
|
const shadow = this.isDark
|
|
|
|
|
? '0 0 0 0.5px rgba(255, 255, 255, 0.1) inset, 0px 4px 16px rgba(0, 0, 0, 0.1), 0px 8px 24px rgba(0, 0, 0, 0.08)'
|
|
|
|
|
: '0 0 0 0.5px rgba(255, 255, 255, 0.15) inset, 0px 4px 16px rgba(0, 0, 0, 0.06), 0px 8px 24px rgba(0, 0, 0, 0.04)';
|
|
|
|
|
|
|
|
|
|
this.el.style.background = bgColor;
|
|
|
|
|
this.el.style.boxShadow = shadow;
|
|
|
|
|
} else {
|
|
|
|
|
this.applyFallback();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.applyFallback();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setupResizeObserver() {
|
|
|
|
|
if (!this.svgSupported) return;
|
|
|
|
|
|
|
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
|
|
|
if ('requestIdleCallback' in window) {
|
|
|
|
|
window.requestIdleCallback(() => this.updateDisplacementMap(), { timeout: 100 });
|
|
|
|
|
} else {
|
|
|
|
|
setTimeout(() => this.updateDisplacementMap(), 0);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
this.resizeObserver.observe(this.el);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateDisplacementMap() {
|
|
|
|
|
if (!this.svgSupported || !this.feImage) return;
|
|
|
|
|
|
|
|
|
|
const rect = this.el.getBoundingClientRect();
|
|
|
|
|
const actualWidth = Math.max(Math.round(rect.width) || 400, 10);
|
|
|
|
|
const actualHeight = Math.max(Math.round(rect.height) || 200, 10);
|
|
|
|
|
const edgeSize = Math.min(actualWidth, actualHeight) * (this.config.borderWidth * 0.5);
|
|
|
|
|
|
|
|
|
|
const svgContent = `
|
|
|
|
|
<svg viewBox="0 0 ${actualWidth} ${actualHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
|
<defs>
|
|
|
|
|
<linearGradient id="${this.redGradId}" x1="100%" y1="0%" x2="0%" y2="0%">
|
|
|
|
|
<stop offset="0%" stop-color="#0000"/>
|
|
|
|
|
<stop offset="100%" stop-color="red"/>
|
|
|
|
|
</linearGradient>
|
|
|
|
|
<linearGradient id="${this.blueGradId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
|
|
|
<stop offset="0%" stop-color="#0000"/>
|
|
|
|
|
<stop offset="100%" stop-color="blue"/>
|
|
|
|
|
</linearGradient>
|
|
|
|
|
</defs>
|
|
|
|
|
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" fill="black"></rect>
|
|
|
|
|
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${this.config.borderRadius}" fill="url(#${this.redGradId})" />
|
|
|
|
|
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${this.config.borderRadius}" fill="url(#${this.blueGradId})" style="mix-blend-mode: ${this.config.mixBlendMode}" />
|
|
|
|
|
<rect x="${edgeSize}" y="${edgeSize}" width="${actualWidth - edgeSize * 2}" height="${actualHeight - edgeSize * 2}" rx="${this.config.borderRadius}" fill="hsl(0 0% ${this.config.brightness}% / ${this.config.opacity})" style="filter:blur(${this.config.blur}px)" />
|
|
|
|
|
</svg>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const dataUrl = `data:image/svg+xml,${encodeURIComponent(svgContent)}`;
|
|
|
|
|
this.feImage.setAttribute('href', dataUrl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
|
this.isMounted = false;
|
|
|
|
|
|
|
|
|
|
if (this.darkModeQuery && this.darkModeListener) {
|
|
|
|
|
this.darkModeQuery.removeEventListener('change', this.darkModeListener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.resizeObserver) {
|
|
|
|
|
this.resizeObserver.disconnect();
|
|
|
|
|
this.resizeObserver = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.svgContainer && this.svgContainer.parentNode) {
|
|
|
|
|
this.svgContainer.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.contentWrapper && this.contentWrapper.parentNode) {
|
|
|
|
|
while (this.contentWrapper.firstChild) {
|
|
|
|
|
this.el.appendChild(this.contentWrapper.firstChild);
|
|
|
|
|
}
|
|
|
|
|
this.contentWrapper.remove();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return globalSvgDefs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function initGlassSurface(selector, options = {}) {
|
|
|
|
|
const elements = typeof selector === 'string' ? document.querySelectorAll(selector) : [selector];
|
|
|
|
|
const defsContainer = ensureGlobalDefs();
|
|
|
|
|
function applyGlassSurface(el, config) {
|
|
|
|
|
if (!el || el.dataset.hasGlass) return null;
|
|
|
|
|
if (typeof GlassSurface !== 'function') return null;
|
|
|
|
|
el.dataset.hasGlass = "true";
|
|
|
|
|
const instance = new GlassSurface(el, config);
|
|
|
|
|
el._glassSurfaceInstance = instance;
|
|
|
|
|
return instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elements.forEach((el, index) => {
|
|
|
|
|
if (!el || el.dataset.glassInitialized) return;
|
|
|
|
|
el.dataset.glassInitialized = 'true';
|
|
|
|
|
function destroyGlassSurface(el) {
|
|
|
|
|
if (!el || !el._glassSurfaceInstance) return;
|
|
|
|
|
el._glassSurfaceInstance.destroy();
|
|
|
|
|
el._glassSurfaceInstance = null;
|
|
|
|
|
delete el.dataset.hasGlass;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const id = `glass-surf-${index}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
|
const filterId = `filter-${id}`;
|
|
|
|
|
const redGradId = `red-grad-${id}`;
|
|
|
|
|
const blueGradId = `blue-grad-${id}`;
|
|
|
|
|
function initLiquidGlass() {
|
|
|
|
|
const config = {
|
|
|
|
|
width: 200,
|
|
|
|
|
height: 25,
|
|
|
|
|
borderRadius: 20,
|
|
|
|
|
borderWidth: 0.07,
|
|
|
|
|
brightness: 50,
|
|
|
|
|
opacity: 0.93,
|
|
|
|
|
blur: 11,
|
|
|
|
|
displace: 0,
|
|
|
|
|
backgroundOpacity: 0,
|
|
|
|
|
saturation: 1,
|
|
|
|
|
distortionScale: -180,
|
|
|
|
|
redOffset: 0,
|
|
|
|
|
greenOffset: 10,
|
|
|
|
|
blueOffset: 20,
|
|
|
|
|
xChannel: 'R',
|
|
|
|
|
yChannel: 'G',
|
|
|
|
|
mixBlendMode: 'difference'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
borderRadius: options.borderRadius !== undefined ? Number(options.borderRadius) : 50,
|
|
|
|
|
borderWidth: options.borderWidth !== undefined ? Number(options.borderWidth) : 0,
|
|
|
|
|
brightness: options.brightness !== undefined ? Number(options.brightness) : 100,
|
|
|
|
|
opacity: options.opacity !== undefined ? Number(options.opacity) : 0.93,
|
|
|
|
|
blur: options.blur !== undefined ? Number(options.blur) : 15,
|
|
|
|
|
displace: options.displace !== undefined ? Number(options.displace) : 0.5,
|
|
|
|
|
backgroundOpacity: options.backgroundOpacity !== undefined ? Number(options.backgroundOpacity) : 0,
|
|
|
|
|
saturation: options.saturation !== undefined ? Number(options.saturation) : 0,
|
|
|
|
|
distortionScale: options.distortionScale !== undefined ? Number(options.distortionScale) : -180,
|
|
|
|
|
redOffset: options.redOffset !== undefined ? Number(options.redOffset) : -50,
|
|
|
|
|
greenOffset: options.greenOffset !== undefined ? Number(options.greenOffset) : -50,
|
|
|
|
|
blueOffset: options.blueOffset !== undefined ? Number(options.blueOffset) : 20,
|
|
|
|
|
xChannel: options.xChannel || 'R',
|
|
|
|
|
yChannel: options.yChannel || 'G',
|
|
|
|
|
mixBlendMode: options.mixBlendMode || 'difference'
|
|
|
|
|
};
|
|
|
|
|
const eagerSelectors = [
|
|
|
|
|
'.nav-links',
|
|
|
|
|
'.discord-copy-bar',
|
|
|
|
|
'.platform-pill',
|
|
|
|
|
'.hero-cta-light',
|
|
|
|
|
'#navIslandMain',
|
|
|
|
|
'#navbarSearchToggle',
|
|
|
|
|
'.hero-icon-group',
|
|
|
|
|
'.details-overlay-actions .action-icon-btn',
|
|
|
|
|
'.cta-button-wrapper .cta-button',
|
|
|
|
|
'.spotlight-actions .cta-button',
|
|
|
|
|
'.edit-profile-pill'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const filterGroup = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
|
|
|
filterGroup.innerHTML = `
|
|
|
|
|
<filter id="${filterId}" color-interpolation-filters="sRGB" x="-50%" y="-50%" width="200%" height="200%">
|
|
|
|
|
<feImage id="fe-map-${id}" x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" result="map" />
|
|
|
|
|
<feDisplacementMap id="disp-red-${id}" in="SourceGraphic" in2="map" scale="${config.distortionScale + config.redOffset}" xChannelSelector="${config.xChannel}" yChannelSelector="${config.yChannel}" result="dispRed" />
|
|
|
|
|
<feColorMatrix in="dispRed" type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="red" />
|
|
|
|
|
<feDisplacementMap id="disp-green-${id}" in="SourceGraphic" in2="map" scale="${config.distortionScale + config.greenOffset}" xChannelSelector="${config.xChannel}" yChannelSelector="${config.yChannel}" result="dispGreen" />
|
|
|
|
|
<feColorMatrix in="dispGreen" type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="green" />
|
|
|
|
|
<feDisplacementMap id="disp-blue-${id}" in="SourceGraphic" in2="map" scale="${config.distortionScale + config.blueOffset}" xChannelSelector="${config.xChannel}" yChannelSelector="${config.yChannel}" result="dispBlue" />
|
|
|
|
|
<feColorMatrix in="dispBlue" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="blue" />
|
|
|
|
|
<feBlend in="red" in2="green" mode="screen" result="rg" />
|
|
|
|
|
<feBlend in="rg" in2="blue" mode="screen" result="output" />
|
|
|
|
|
<feGaussianBlur id="blur-${id}" in="output" stdDeviation="${config.displace}" />
|
|
|
|
|
</filter>
|
|
|
|
|
`;
|
|
|
|
|
defsContainer.appendChild(filterGroup);
|
|
|
|
|
document.querySelectorAll(eagerSelectors.join(', ')).forEach(el => applyGlassSurface(el, config));
|
|
|
|
|
|
|
|
|
|
const rawContent = el.innerHTML;
|
|
|
|
|
el.innerHTML = '';
|
|
|
|
|
if (!window._cardPlayBtnGlassObserver) {
|
|
|
|
|
window._cardPlayBtnGlassObserver = new IntersectionObserver((entries) => {
|
|
|
|
|
entries.forEach(entry => {
|
|
|
|
|
if (entry.isIntersecting) {
|
|
|
|
|
applyGlassSurface(entry.target, config);
|
|
|
|
|
window._cardPlayBtnGlassObserver.unobserve(entry.target);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, { rootMargin: '200px' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const backdropLayer = document.createElement('span');
|
|
|
|
|
backdropLayer.className = 'glass-surface__backdrop';
|
|
|
|
|
|
|
|
|
|
const contentLayer = document.createElement('span');
|
|
|
|
|
contentLayer.className = 'glass-surface__content';
|
|
|
|
|
contentLayer.innerHTML = rawContent;
|
|
|
|
|
|
|
|
|
|
el.appendChild(backdropLayer);
|
|
|
|
|
el.appendChild(contentLayer);
|
|
|
|
|
el.classList.add('glass-surface');
|
|
|
|
|
|
|
|
|
|
el.style.borderRadius = `${config.borderRadius}px`;
|
|
|
|
|
el.style.setProperty('--glass-radius', `${config.borderRadius}px`);
|
|
|
|
|
el.style.setProperty('--glass-saturation', config.saturation.toString());
|
|
|
|
|
|
|
|
|
|
const feImage = defsContainer.querySelector(`#fe-map-${id}`);
|
|
|
|
|
|
|
|
|
|
const updateMap = () => {
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
const width = Math.max(Math.round(rect.width), 40);
|
|
|
|
|
const height = Math.max(Math.round(rect.height), 20);
|
|
|
|
|
const edgeSize = Math.min(width, height) * (config.borderWidth * 0.5);
|
|
|
|
|
|
|
|
|
|
const svgContent = `
|
|
|
|
|
<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
|
<defs>
|
|
|
|
|
<linearGradient id="${redGradId}" x1="100%" y1="0%" x2="0%" y2="0%">
|
|
|
|
|
<stop offset="0%" stop-color="#0000"/>
|
|
|
|
|
<stop offset="100%" stop-color="red"/>
|
|
|
|
|
</linearGradient>
|
|
|
|
|
<linearGradient id="${blueGradId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
|
|
|
<stop offset="0%" stop-color="#0000"/>
|
|
|
|
|
<stop offset="100%" stop-color="blue"/>
|
|
|
|
|
</linearGradient>
|
|
|
|
|
</defs>
|
|
|
|
|
<rect x="0" y="0" width="${width}" height="${height}" fill="black"></rect>
|
|
|
|
|
<rect x="0" y="0" width="${width}" height="${height}" rx="${config.borderRadius}" fill="url(#${redGradId})" />
|
|
|
|
|
<rect x="0" y="0" width="${width}" height="${height}" rx="${config.borderRadius}" fill="url(#${blueGradId})" style="mix-blend-mode: ${config.mixBlendMode}" />
|
|
|
|
|
<rect x="${edgeSize}" y="${edgeSize}" width="${Math.max(0, width - edgeSize * 2)}" height="${Math.max(0, height - edgeSize * 2)}" rx="${config.borderRadius}" fill="hsl(0 0% ${config.brightness}% / ${config.opacity})" style="filter:blur(${config.blur}px)" />
|
|
|
|
|
</svg>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
if (feImage) {
|
|
|
|
|
feImage.setAttribute('href', `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`);
|
|
|
|
|
if (!window._sharedDomObserver) {
|
|
|
|
|
window._sharedDomObserver = new MutationObserver((mutations) => {
|
|
|
|
|
for (const m of mutations) {
|
|
|
|
|
m.addedNodes.forEach(node => {
|
|
|
|
|
if (node.nodeType !== 1) return;
|
|
|
|
|
if (node.matches?.('.card-play-btn') && !node.dataset.hasGlass) {
|
|
|
|
|
window._cardPlayBtnGlassObserver.observe(node);
|
|
|
|
|
}
|
|
|
|
|
node.querySelectorAll?.('.card-play-btn:not([data-has-glass])').forEach(el => {
|
|
|
|
|
window._cardPlayBtnGlassObserver.observe(el);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
window._sharedDomObserver.observe(document.body, { childList: true, subtree: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
el.style.setProperty('--glass-filter-id', `url(#${filterId})`);
|
|
|
|
|
el.style.setProperty('--glass-radius', `${config.borderRadius}px`);
|
|
|
|
|
el.style.setProperty('--glass-saturation', config.saturation.toString());
|
|
|
|
|
el.style.borderRadius = `${config.borderRadius}px`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
updateMap();
|
|
|
|
|
const ro = new ResizeObserver(() => requestAnimationFrame(updateMap));
|
|
|
|
|
ro.observe(el);
|
|
|
|
|
document.querySelectorAll('.card-play-btn:not([data-has-glass])').forEach(el => {
|
|
|
|
|
window._cardPlayBtnGlassObserver.observe(el);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (document.readyState === "loading") {
|
|
|
|
|
document.addEventListener("DOMContentLoaded", () => initLiquidGlass());
|
|
|
|
|
} else {
|
|
|
|
|
initLiquidGlass();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { GlassSurface, applyGlassSurface, destroyGlassSurface, initLiquidGlass };
|
|
|
|
|
|