feat: reimport liquid glass shader

This commit is contained in:
JOJO 2025-12-04 15:47:32 +08:00
parent eb7ccf1dd2
commit d97955fdc1
2 changed files with 534 additions and 2 deletions

View File

@ -0,0 +1,287 @@
<template>
<Teleport to="body">
<div v-if="enabled" class="liquid-glass-stage">
<svg
class="liquid-glass-filter"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
>
<defs>
<filter
:id="svgFilterId"
x="0"
y="0"
:width="panelSize.width"
:height="panelSize.height"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feImage
:id="displacementMapId"
x="0"
y="0"
:width="panelSize.width"
:height="panelSize.height"
preserveAspectRatio="none"
:xlink:href="displacementMapUrl || undefined"
/>
<feDisplacementMap
in="SourceGraphic"
:in2="displacementMapId"
xChannelSelector="R"
yChannelSelector="G"
:scale="displacementScale"
/>
</filter>
</defs>
</svg>
<div
class="liquid-glass-panel"
ref="panelRef"
:style="panelInlineStyle"
@pointerdown="handlePointerDown"
></div>
<div class="liquid-glass-toolbar">
<button type="button" class="liquid-glass-btn ghost" @click="personalization.toggleLiquidGlassExperiment()">
关闭实验
</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { usePersonalizationStore } from '@/stores/personalization';
defineOptions({ name: 'LiquidGlassWidget' });
const PANEL_WIDTH = 300;
const PANEL_HEIGHT = 200;
const EDGE_GAP = 10;
const CANVAS_DPI = 1; // demo
interface PanelPosition {
left: number;
top: number;
}
const personalization = usePersonalizationStore();
const enabled = computed(() => personalization.experiments.liquidGlassEnabled);
const panelRef = ref<HTMLDivElement | null>(null);
const panelPosition = ref<PanelPosition>({ left: EDGE_GAP, top: EDGE_GAP });
const dragging = ref(false);
const pointerId = ref<number | null>(null);
const dragOffset = ref({ x: 0, y: 0 });
const displacementMapUrl = ref<string>('');
const displacementScale = ref(0);
const filterSeed = Math.random().toString(36).slice(2, 11);
const svgFilterId = `liquid-glass-${filterSeed}_filter`;
const displacementMapId = `liquid-glass-${filterSeed}_map`;
const panelSize = { width: PANEL_WIDTH, height: PANEL_HEIGHT };
const clampChannel = (value: number) => Math.max(0, Math.min(255, value));
const smoothStep = (a: number, b: number, t: number) => {
const x = Math.max(0, Math.min(1, (t - a) / (b - a)));
return x * x * (3 - 2 * x);
};
const length2d = (x: number, y: number) => Math.sqrt(x * x + y * y);
const roundedRectSDF = (x: number, y: number, width: number, height: number, radius: number) => {
const qx = Math.abs(x) - width + radius;
const qy = Math.abs(y) - height + radius;
return Math.min(Math.max(qx, qy), 0) + length2d(Math.max(qx, 0), Math.max(qy, 0)) - radius;
};
const ensureDisplacementReady = () => {
if (displacementMapUrl.value) {
return;
}
const canvas = document.createElement('canvas');
canvas.width = PANEL_WIDTH * CANVAS_DPI;
canvas.height = PANEL_HEIGHT * CANVAS_DPI;
const context = canvas.getContext('2d');
if (!context) {
return;
}
const w = canvas.width;
const h = canvas.height;
const data = new Uint8ClampedArray(w * h * 4);
const rawValues = new Float32Array(w * h * 2);
let maxScale = 0;
let rawIndex = 0;
const fragment = (uv: { x: number; y: number }) => {
const ix = uv.x - 0.5;
const iy = uv.y - 0.5;
const distanceToEdge = roundedRectSDF(ix, iy, 0.3, 0.2, 0.6);
const displacement = smoothStep(0.8, 0, distanceToEdge - 0.15);
const scaled = smoothStep(0, 1, displacement);
return {
x: ix * scaled + 0.5,
y: iy * scaled + 0.5
};
};
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const pos = fragment({ x: x / w, y: y / h });
const dx = pos.x * w - x;
const dy = pos.y * h - y;
maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy));
rawValues[rawIndex++] = dx;
rawValues[rawIndex++] = dy;
}
}
maxScale *= 0.5;
const safeScale = maxScale === 0 ? 1 : maxScale;
rawIndex = 0;
for (let i = 0; i < data.length; i += 4) {
const r = rawValues[rawIndex++] / safeScale + 0.5;
const g = rawValues[rawIndex++] / safeScale + 0.5;
data[i] = clampChannel(r * 255);
data[i + 1] = clampChannel(g * 255);
data[i + 2] = 0;
data[i + 3] = 255;
}
context.putImageData(new ImageData(data, w, h), 0, 0);
displacementMapUrl.value = canvas.toDataURL('image/png');
displacementScale.value = safeScale / CANVAS_DPI;
};
const panelInlineStyle = computed(() => ({
width: `${PANEL_WIDTH}px`,
height: `${PANEL_HEIGHT}px`,
left: `${panelPosition.value.left}px`,
top: `${panelPosition.value.top}px`,
borderRadius: '150px',
backdropFilter: `url(#${svgFilterId}) blur(0.25px) brightness(1.5) saturate(1.1)`,
WebkitBackdropFilter: `url(#${svgFilterId}) blur(0.25px) brightness(1.5) saturate(1.1)`
}));
const clampPosition = (left: number, top: number) => {
if (typeof window === 'undefined') {
return { left, top };
}
const maxLeft = Math.max(EDGE_GAP, window.innerWidth - PANEL_WIDTH - EDGE_GAP);
const maxTop = Math.max(EDGE_GAP, window.innerHeight - PANEL_HEIGHT - EDGE_GAP);
return {
left: Math.min(Math.max(EDGE_GAP, left), maxLeft),
top: Math.min(Math.max(EDGE_GAP, top), maxTop)
};
};
const applyPanelPosition = (left: number, top: number, persist = false) => {
const next = clampPosition(left, top);
panelPosition.value = next;
if (persist) {
personalization.updateLiquidGlassPosition(next);
}
};
const initPosition = () => {
if (typeof window === 'undefined') {
return;
}
const saved = personalization.experiments.liquidGlassPosition;
if (saved) {
applyPanelPosition(saved.left, saved.top, false);
return;
}
const centeredLeft = Math.round(window.innerWidth / 2 - PANEL_WIDTH / 2);
const centeredTop = Math.round(window.innerHeight / 2 - PANEL_HEIGHT / 2);
applyPanelPosition(centeredLeft, centeredTop, true);
};
const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
if (!panelRef.value) {
return;
}
dragging.value = true;
pointerId.value = event.pointerId;
panelRef.value.setPointerCapture?.(event.pointerId);
const rect = panelRef.value.getBoundingClientRect();
dragOffset.value = { x: event.clientX - rect.left, y: event.clientY - rect.top };
panelRef.value.style.cursor = 'grabbing';
};
const handlePointerMove = (event: PointerEvent) => {
if (!dragging.value || pointerId.value !== event.pointerId) {
return;
}
const left = event.clientX - dragOffset.value.x;
const top = event.clientY - dragOffset.value.y;
applyPanelPosition(left, top, false);
};
const handlePointerUpGlobal = (event: PointerEvent) => {
if (!dragging.value || pointerId.value !== event.pointerId) {
return;
}
dragging.value = false;
pointerId.value = null;
panelRef.value?.releasePointerCapture?.(event.pointerId);
panelRef.value?.style.setProperty('cursor', 'grab');
applyPanelPosition(panelPosition.value.left, panelPosition.value.top, true);
};
const handleResize = () => {
applyPanelPosition(panelPosition.value.left, panelPosition.value.top, true);
};
const attachWindowListeners = () => {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUpGlobal);
window.addEventListener('pointercancel', handlePointerUpGlobal);
window.addEventListener('resize', handleResize);
};
const detachWindowListeners = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUpGlobal);
window.removeEventListener('pointercancel', handlePointerUpGlobal);
window.removeEventListener('resize', handleResize);
};
watch(
enabled,
async (active) => {
if (!active) {
dragging.value = false;
if (pointerId.value !== null) {
panelRef.value?.releasePointerCapture?.(pointerId.value);
panelRef.value?.style.setProperty('cursor', 'grab');
pointerId.value = null;
}
return;
}
await nextTick();
ensureDisplacementReady();
initPosition();
},
{ immediate: false }
);
onMounted(() => {
if (enabled.value) {
ensureDisplacementReady();
initPosition();
}
attachWindowListeners();
});
onBeforeUnmount(() => {
detachWindowListeners();
});
</script>

View File

@ -190,8 +190,9 @@
background: rgba(255, 255, 255, 0.92);
align-self: flex-start;
height: auto;
max-height: 220px;
overflow: hidden;
max-height: none;
overflow: visible;
flex-wrap: wrap;
}
.personal-tab-button {
@ -206,6 +207,15 @@
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.personal-tab-desc {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--claude-text-tertiary);
margin-top: 2px;
letter-spacing: 0.4px;
}
.personal-tab-button.active {
background: rgba(189, 93, 58, 0.12);
color: var(--claude-accent);
@ -256,6 +266,7 @@
flex-direction: row;
padding: 14px;
height: auto;
flex-wrap: wrap;
}
.personal-tab-button {
@ -295,6 +306,240 @@
font-size: 13px;
}
.experiment-page {
display: flex;
flex-direction: column;
gap: 28px;
background: rgba(255, 255, 255, 0.95);
border-radius: 24px;
border: 1px solid rgba(118, 103, 84, 0.18);
padding: 28px 30px;
}
.experiment-hero {
display: grid;
grid-template-columns: minmax(240px, 1fr) minmax(0, 1.4fr);
gap: 28px;
align-items: center;
}
.experiment-visual {
position: relative;
border-radius: 32px;
min-height: 220px;
overflow: hidden;
background: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.65), rgba(104, 147, 255, 0.2))
rgba(28, 27, 41, 0.95);
box-shadow: 0 25px 60px rgba(24, 20, 37, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.experiment-visual::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(140deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0));
mix-blend-mode: screen;
}
.experiment-visual-glow {
position: absolute;
width: 140%;
height: 140%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.3), transparent 60%);
filter: blur(50px);
transform: translate(-15%, -20%);
opacity: 0.8;
animation: experimentDrift 8s ease-in-out infinite alternate;
}
.experiment-grid {
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: 32px 32px;
opacity: 0.4;
}
.experiment-orb {
position: absolute;
width: 120px;
height: 120px;
border-radius: 50%;
filter: blur(1px);
background: radial-gradient(circle, rgba(98, 178, 255, 0.8), rgba(255, 255, 255, 0));
animation: experimentDrift 10s ease-in-out infinite alternate;
}
.experiment-orb.orb-one {
top: 20%;
left: 14%;
}
.experiment-orb.orb-two {
bottom: 10%;
right: 8%;
background: radial-gradient(circle, rgba(255, 166, 122, 0.85), rgba(255, 255, 255, 0));
}
.experiment-copy h3 {
font-size: 24px;
margin-bottom: 12px;
}
.experiment-copy p {
color: var(--claude-text-secondary);
line-height: 1.6;
}
.experiment-subtitle {
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.4em;
color: var(--claude-accent);
margin-bottom: 6px;
}
.experiment-toggle-card {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
border-radius: 22px;
border: 1px solid rgba(118, 103, 84, 0.2);
padding: 20px 24px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 20px 45px rgba(16, 24, 40, 0.08);
}
.experiment-toggle-info h4 {
margin: 0 0 6px;
font-size: 18px;
}
.experiment-toggle-info p {
margin: 0;
color: var(--claude-text-secondary);
}
.experiment-toggle .toggle-text {
max-width: 320px;
}
.experiment-note {
border-radius: 18px;
padding: 18px 22px;
background: rgba(255, 255, 255, 0.85);
border: 1px dashed rgba(118, 103, 84, 0.3);
}
.experiment-note p {
margin-bottom: 10px;
font-weight: 600;
}
.experiment-hint-list {
margin: 0;
padding-left: 20px;
line-height: 1.6;
color: var(--claude-text-secondary);
}
/* Liquid glass overlay */
.liquid-glass-stage {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1200;
}
.liquid-glass-filter {
position: fixed;
inset: 0;
width: 0;
height: 0;
pointer-events: none;
z-index: 1199;
}
.liquid-glass-panel {
position: absolute;
pointer-events: auto;
border-radius: 150px;
overflow: hidden;
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.25),
0 -10px 25px inset rgba(0, 0, 0, 0.15),
0 -1px 4px 1px inset rgba(255, 255, 255, 0.74);
cursor: grab;
background: transparent;
touch-action: none;
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(0.25px);
-webkit-backdrop-filter: blur(0.25px);
}
.liquid-glass-panel:active {
cursor: grabbing;
}
.liquid-glass-toolbar {
position: fixed;
right: 20px;
bottom: 20px;
display: flex;
gap: 10px;
pointer-events: auto;
z-index: 1210;
}
.liquid-glass-btn {
border: none;
border-radius: 999px;
padding: 9px 18px;
font-size: 13px;
color: white;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.12));
backdrop-filter: blur(18px);
cursor: pointer;
transition: transform 0.15s ease, background 0.2s ease;
}
.liquid-glass-btn:hover:not(:disabled) {
transform: translateY(-1px);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.2));
}
.liquid-glass-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.liquid-glass-btn.ghost {
background: rgba(15, 15, 25, 0.6);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.liquid-glass-btn.ghost:hover {
background: rgba(15, 15, 25, 0.75);
}
@keyframes experimentDrift {
from {
transform: translate(-5%, -5%) scale(1);
}
to {
transform: translate(5%, 5%) scale(1.05);
}
}
@media (max-width: 960px) {
.experiment-hero {
grid-template-columns: 1fr;
}
}
.run-mode-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));