feat: reimport liquid glass shader
This commit is contained in:
parent
eb7ccf1dd2
commit
d97955fdc1
287
static/src/components/experiments/LiquidGlassWidget.vue
Normal file
287
static/src/components/experiments/LiquidGlassWidget.vue
Normal 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>
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user