fancy-ui-svelte 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -81
- package/dist/fancy-ui/animated-testimonials/README.md +44 -0
- package/dist/fancy-ui/animated-tooltip/README.md +40 -0
- package/dist/fancy-ui/apple-card-carousel/README.md +46 -0
- package/dist/fancy-ui/bento-grid/BentoGrid.test.d.ts +1 -0
- package/dist/fancy-ui/bento-grid/BentoGrid.test.js +109 -0
- package/dist/fancy-ui/border-beam/README.md +37 -0
- package/dist/fancy-ui/card-3d/CardContainer.test.d.ts +1 -0
- package/dist/fancy-ui/card-3d/CardContainer.test.js +118 -0
- package/dist/fancy-ui/card-spotlight/CardSpotlight.test.d.ts +1 -0
- package/dist/fancy-ui/card-spotlight/CardSpotlight.test.js +58 -0
- package/dist/fancy-ui/displacement-text/README.md +39 -0
- package/dist/fancy-ui/dock/README.md +1 -1
- package/dist/fancy-ui/editorial-engine/EditorialEngine.test.d.ts +1 -0
- package/dist/fancy-ui/editorial-engine/EditorialEngine.test.js +54 -0
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +1250 -1108
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +11 -5
- package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +48 -0
- package/dist/fancy-ui/fluid-cursor/README.md +57 -6
- package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +72 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.js +125 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.d.ts +1 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +39 -0
- package/dist/fancy-ui/fluid-cursor/index.d.ts +1 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +36 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +926 -0
- package/dist/fancy-ui/frosted-glass/FrostedGlass.svelte +5 -3
- package/dist/fancy-ui/frosted-glass/README.md +4 -2
- package/dist/fancy-ui/index.d.ts +1 -0
- package/dist/fancy-ui/index.js +1 -0
- package/dist/fancy-ui/line-hover-link/README.md +37 -0
- package/dist/fancy-ui/line-reveal/LineReveal.test.d.ts +1 -0
- package/dist/fancy-ui/line-reveal/LineReveal.test.js +77 -0
- package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte +19 -2
- package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte.d.ts +4 -0
- package/dist/fancy-ui/liquid-glass/LiquidGlass.test.js +14 -0
- package/dist/fancy-ui/liquid-glass/README.md +7 -1
- package/dist/fancy-ui/liquid-text/LiquidText.svelte +1151 -0
- package/dist/fancy-ui/liquid-text/LiquidText.svelte.d.ts +37 -0
- package/dist/fancy-ui/liquid-text/LiquidText.test.d.ts +1 -0
- package/dist/fancy-ui/liquid-text/LiquidText.test.js +219 -0
- package/dist/fancy-ui/liquid-text/README.md +139 -0
- package/dist/fancy-ui/liquid-text/index.d.ts +2 -0
- package/dist/fancy-ui/liquid-text/index.js +1 -0
- package/dist/fancy-ui/marquee/README.md +57 -0
- package/dist/fancy-ui/matrix-rain/MatrixRain.test.d.ts +1 -0
- package/dist/fancy-ui/matrix-rain/MatrixRain.test.js +30 -0
- package/dist/fancy-ui/matrix-rain/README.md +42 -0
- package/dist/fancy-ui/noise-reveal/README.md +52 -0
- package/dist/fancy-ui/rainbow-button/README.md +36 -0
- package/dist/fancy-ui/registry.js +144 -20
- package/dist/fancy-ui/ripple-button/README.md +36 -0
- package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.d.ts +1 -0
- package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.js +45 -0
- package/dist/fancy-ui/terminal-text/README.md +48 -0
- package/dist/fancy-ui/terminal-text/TerminalText.test.d.ts +1 -0
- package/dist/fancy-ui/terminal-text/TerminalText.test.js +88 -0
- package/package.json +18 -4
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface LiquidTextProps {
|
|
3
|
+
/** Text rendered into the fluid texture (or as static fallback text). */
|
|
4
|
+
text?: string;
|
|
5
|
+
/** CSS font-family. Empty string resolves to getComputedStyle(host).fontFamily. */
|
|
6
|
+
font?: string;
|
|
7
|
+
/** Font size in px. 0 auto-fits the text to the container's width. */
|
|
8
|
+
fontSize?: number;
|
|
9
|
+
/** CSS font-weight for the rasterized/fallback text. */
|
|
10
|
+
fontWeight?: number | string;
|
|
11
|
+
/** Text color used in light mode. */
|
|
12
|
+
lightColor?: string;
|
|
13
|
+
/** Text color used in dark mode. */
|
|
14
|
+
darkColor?: string;
|
|
15
|
+
/** Additional CSS classes applied to the root element. */
|
|
16
|
+
class?: string;
|
|
17
|
+
/** Geometric UV warp gain — how far the fluid velocity displaces the text's UVs. */
|
|
18
|
+
strength?: number;
|
|
19
|
+
/** Splat radius in screen pixels around the pointer. */
|
|
20
|
+
radius?: number;
|
|
21
|
+
/** Multiplier from mouse-delta-per-frame to splat force. */
|
|
22
|
+
forceGain?: number;
|
|
23
|
+
/** Per-frame velocity decay factor (relax-back rate for the smear). */
|
|
24
|
+
dissipation?: number;
|
|
25
|
+
/** Viscous diffusion strength (Jacobi iteration, 8 iters). */
|
|
26
|
+
viscosity?: number;
|
|
27
|
+
/** Chromatic offset = warp amount x this ratio. */
|
|
28
|
+
chromaticRatio?: number;
|
|
29
|
+
/** If window.innerWidth <= staticBelow at mount, render static DOM text instead. */
|
|
30
|
+
staticBelow?: number;
|
|
31
|
+
/** Whether the fluid sim reacts to pointer movement. */
|
|
32
|
+
interactive?: boolean;
|
|
33
|
+
/** Pause the render loop via visibilitychange when the tab/page is hidden. */
|
|
34
|
+
pauseWhenHidden?: boolean;
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<script lang="ts">
|
|
39
|
+
import { cn } from "../../utils.js";
|
|
40
|
+
import { onMount } from "svelte";
|
|
41
|
+
|
|
42
|
+
// ===========================================================================
|
|
43
|
+
// Pure WebGL helpers (stateless — take `gl` as a parameter). Structurally
|
|
44
|
+
// modeled on the FluidCursor pass pipeline (DoubleFBO ping-pong, blit-driven
|
|
45
|
+
// fullscreen passes, Jacobi iteration), rewritten from scratch for a
|
|
46
|
+
// text-displacement sim: velocity-only field (no dye buffer), an added
|
|
47
|
+
// viscous-diffusion pass, and a screen-px splat falloff instead of a
|
|
48
|
+
// gaussian one. See README.md for the full pass order.
|
|
49
|
+
// ===========================================================================
|
|
50
|
+
|
|
51
|
+
type GL = WebGLRenderingContext | WebGL2RenderingContext;
|
|
52
|
+
|
|
53
|
+
interface Fbo {
|
|
54
|
+
texture: WebGLTexture;
|
|
55
|
+
framebuffer: WebGLFramebuffer;
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
texelSizeX: number;
|
|
59
|
+
texelSizeY: number;
|
|
60
|
+
attach: (unit: number) => number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface DoubleFbo {
|
|
64
|
+
width: number;
|
|
65
|
+
height: number;
|
|
66
|
+
texelSizeX: number;
|
|
67
|
+
texelSizeY: number;
|
|
68
|
+
read: Fbo;
|
|
69
|
+
write: Fbo;
|
|
70
|
+
swap: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface TexFormat {
|
|
74
|
+
internalFormat: number;
|
|
75
|
+
format: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface GlExt {
|
|
79
|
+
isWebGL2: boolean;
|
|
80
|
+
halfFloatTexType: number;
|
|
81
|
+
supportLinearFiltering: boolean;
|
|
82
|
+
velocityFormat: TexFormat;
|
|
83
|
+
scalarFormat: TexFormat;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function compileShader(gl: GL, type: number, source: string): WebGLShader {
|
|
87
|
+
const shader = gl.createShader(type);
|
|
88
|
+
if (!shader) throw new Error("LiquidText: unable to create shader");
|
|
89
|
+
gl.shaderSource(shader, source);
|
|
90
|
+
gl.compileShader(shader);
|
|
91
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
92
|
+
const info = gl.getShaderInfoLog(shader) ?? "unknown error";
|
|
93
|
+
gl.deleteShader(shader);
|
|
94
|
+
throw new Error(`LiquidText: shader compile failed: ${info}`);
|
|
95
|
+
}
|
|
96
|
+
return shader;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function linkProgram(gl: GL, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {
|
|
100
|
+
const program = gl.createProgram();
|
|
101
|
+
if (!program) throw new Error("LiquidText: unable to create program");
|
|
102
|
+
gl.attachShader(program, vertexShader);
|
|
103
|
+
gl.attachShader(program, fragmentShader);
|
|
104
|
+
gl.linkProgram(program);
|
|
105
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
106
|
+
const info = gl.getProgramInfoLog(program) ?? "unknown error";
|
|
107
|
+
gl.deleteProgram(program);
|
|
108
|
+
throw new Error(`LiquidText: program link failed: ${info}`);
|
|
109
|
+
}
|
|
110
|
+
return program;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function uniformLocations(gl: GL, program: WebGLProgram): Map<string, WebGLUniformLocation> {
|
|
114
|
+
const map = new Map<string, WebGLUniformLocation>();
|
|
115
|
+
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;
|
|
116
|
+
for (let i = 0; i < count; i++) {
|
|
117
|
+
const info = gl.getActiveUniform(program, i);
|
|
118
|
+
if (!info) continue;
|
|
119
|
+
const loc = gl.getUniformLocation(program, info.name);
|
|
120
|
+
if (loc) map.set(info.name, loc);
|
|
121
|
+
}
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class GlProgram {
|
|
126
|
+
readonly program: WebGLProgram;
|
|
127
|
+
readonly uniforms: Map<string, WebGLUniformLocation>;
|
|
128
|
+
private readonly gl: GL;
|
|
129
|
+
|
|
130
|
+
constructor(gl: GL, vertexShader: WebGLShader, fragmentSource: string) {
|
|
131
|
+
this.gl = gl;
|
|
132
|
+
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
|
|
133
|
+
this.program = linkProgram(gl, vertexShader, fragmentShader);
|
|
134
|
+
gl.deleteShader(fragmentShader);
|
|
135
|
+
this.uniforms = uniformLocations(gl, this.program);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
use() {
|
|
139
|
+
this.gl.useProgram(this.program);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
set1f(name: string, value: number) {
|
|
143
|
+
const loc = this.uniforms.get(name);
|
|
144
|
+
if (loc) this.gl.uniform1f(loc, value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
set2f(name: string, x: number, y: number) {
|
|
148
|
+
const loc = this.uniforms.get(name);
|
|
149
|
+
if (loc) this.gl.uniform2f(loc, x, y);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
set1i(name: string, value: number) {
|
|
153
|
+
const loc = this.uniforms.get(name);
|
|
154
|
+
if (loc) this.gl.uniform1i(loc, value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
dispose() {
|
|
158
|
+
this.gl.deleteProgram(this.program);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function createFbo(
|
|
163
|
+
gl: GL,
|
|
164
|
+
width: number,
|
|
165
|
+
height: number,
|
|
166
|
+
fmt: TexFormat,
|
|
167
|
+
type: number,
|
|
168
|
+
filter: number
|
|
169
|
+
): Fbo {
|
|
170
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
171
|
+
const texture = gl.createTexture();
|
|
172
|
+
if (!texture) throw new Error("LiquidText: unable to allocate texture");
|
|
173
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
174
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
|
|
175
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
|
|
176
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
177
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
178
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, fmt.internalFormat, width, height, 0, fmt.format, type, null);
|
|
179
|
+
|
|
180
|
+
const framebuffer = gl.createFramebuffer();
|
|
181
|
+
if (!framebuffer) throw new Error("LiquidText: unable to allocate framebuffer");
|
|
182
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
183
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
184
|
+
gl.viewport(0, 0, width, height);
|
|
185
|
+
gl.clearColor(0, 0, 0, 0);
|
|
186
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
texture,
|
|
190
|
+
framebuffer,
|
|
191
|
+
width,
|
|
192
|
+
height,
|
|
193
|
+
texelSizeX: 1 / width,
|
|
194
|
+
texelSizeY: 1 / height,
|
|
195
|
+
attach: (unit: number) => {
|
|
196
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
197
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
198
|
+
return unit;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function deleteFbo(gl: GL, fbo: Fbo | null | undefined) {
|
|
204
|
+
if (!fbo) return;
|
|
205
|
+
gl.deleteTexture(fbo.texture);
|
|
206
|
+
gl.deleteFramebuffer(fbo.framebuffer);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createDoubleFbo(
|
|
210
|
+
gl: GL,
|
|
211
|
+
width: number,
|
|
212
|
+
height: number,
|
|
213
|
+
fmt: TexFormat,
|
|
214
|
+
type: number,
|
|
215
|
+
filter: number
|
|
216
|
+
): DoubleFbo {
|
|
217
|
+
let read = createFbo(gl, width, height, fmt, type, filter);
|
|
218
|
+
let write = createFbo(gl, width, height, fmt, type, filter);
|
|
219
|
+
return {
|
|
220
|
+
width,
|
|
221
|
+
height,
|
|
222
|
+
texelSizeX: read.texelSizeX,
|
|
223
|
+
texelSizeY: read.texelSizeY,
|
|
224
|
+
get read() {
|
|
225
|
+
return read;
|
|
226
|
+
},
|
|
227
|
+
get write() {
|
|
228
|
+
return write;
|
|
229
|
+
},
|
|
230
|
+
swap() {
|
|
231
|
+
const tmp = read;
|
|
232
|
+
read = write;
|
|
233
|
+
write = tmp;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function deleteDoubleFbo(gl: GL, dfbo: DoubleFbo | null | undefined) {
|
|
239
|
+
if (!dfbo) return;
|
|
240
|
+
deleteFbo(gl, dfbo.read);
|
|
241
|
+
deleteFbo(gl, dfbo.write);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function textureRenderable(gl: GL, internalFormat: number, format: number, type: number): boolean {
|
|
245
|
+
const texture = gl.createTexture();
|
|
246
|
+
if (!texture) return false;
|
|
247
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
248
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
249
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
250
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
251
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
252
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null);
|
|
253
|
+
|
|
254
|
+
const framebuffer = gl.createFramebuffer();
|
|
255
|
+
let ok = false;
|
|
256
|
+
if (framebuffer) {
|
|
257
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
258
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
259
|
+
ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
|
|
260
|
+
gl.deleteFramebuffer(framebuffer);
|
|
261
|
+
}
|
|
262
|
+
gl.deleteTexture(texture);
|
|
263
|
+
return ok;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function resolveFormat(
|
|
267
|
+
gl: GL,
|
|
268
|
+
isWebGL2: boolean,
|
|
269
|
+
kind: "velocity" | "scalar",
|
|
270
|
+
type: number
|
|
271
|
+
): TexFormat | null {
|
|
272
|
+
if (!isWebGL2) {
|
|
273
|
+
return textureRenderable(gl, gl.RGBA, gl.RGBA, type)
|
|
274
|
+
? { internalFormat: gl.RGBA, format: gl.RGBA }
|
|
275
|
+
: null;
|
|
276
|
+
}
|
|
277
|
+
const gl2 = gl as WebGL2RenderingContext;
|
|
278
|
+
const candidates: TexFormat[] =
|
|
279
|
+
kind === "velocity"
|
|
280
|
+
? [
|
|
281
|
+
{ internalFormat: gl2.RG16F, format: gl2.RG },
|
|
282
|
+
{ internalFormat: gl2.RGBA16F, format: gl2.RGBA },
|
|
283
|
+
]
|
|
284
|
+
: [
|
|
285
|
+
{ internalFormat: gl2.R16F, format: gl2.RED },
|
|
286
|
+
{ internalFormat: gl2.RG16F, format: gl2.RG },
|
|
287
|
+
{ internalFormat: gl2.RGBA16F, format: gl2.RGBA },
|
|
288
|
+
];
|
|
289
|
+
for (const candidate of candidates) {
|
|
290
|
+
if (textureRenderable(gl, candidate.internalFormat, candidate.format, type)) return candidate;
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function acquireGL(canvas: HTMLCanvasElement): { gl: GL; ext: GlExt } | null {
|
|
296
|
+
const attrs: WebGLContextAttributes = {
|
|
297
|
+
alpha: true,
|
|
298
|
+
depth: false,
|
|
299
|
+
stencil: false,
|
|
300
|
+
antialias: false,
|
|
301
|
+
preserveDrawingBuffer: false,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
let gl: GL | null = canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null;
|
|
305
|
+
let isWebGL2 = !!gl;
|
|
306
|
+
if (!gl) {
|
|
307
|
+
gl = (canvas.getContext("webgl", attrs) ??
|
|
308
|
+
canvas.getContext("experimental-webgl", attrs)) as WebGLRenderingContext | null;
|
|
309
|
+
isWebGL2 = false;
|
|
310
|
+
}
|
|
311
|
+
if (!gl) return null;
|
|
312
|
+
|
|
313
|
+
let halfFloatTexType: number;
|
|
314
|
+
let supportLinearFiltering: boolean;
|
|
315
|
+
|
|
316
|
+
if (isWebGL2) {
|
|
317
|
+
const gl2 = gl as WebGL2RenderingContext;
|
|
318
|
+
gl2.getExtension("EXT_color_buffer_float");
|
|
319
|
+
// This component only ever allocates HALF_FLOAT (RG16F/R16F/RGBA16F)
|
|
320
|
+
// textures, and 16-bit float textures are always texture-filterable
|
|
321
|
+
// in core WebGL2 — OES_texture_float_linear only gates 32-bit FLOAT
|
|
322
|
+
// textures, which never get allocated here. Gating on that
|
|
323
|
+
// extension made the velocity FBO fall back to NEAREST (and the
|
|
324
|
+
// 0.25x sim field get upsampled blocky by the display shader) on
|
|
325
|
+
// the many GPUs/ANGLE backends that expose half-float without
|
|
326
|
+
// float-linear.
|
|
327
|
+
supportLinearFiltering = true;
|
|
328
|
+
halfFloatTexType = gl2.HALF_FLOAT;
|
|
329
|
+
} else {
|
|
330
|
+
// Some WebGL1 drivers require this to make a half-float texture
|
|
331
|
+
// usable as an FBO color attachment; without it, textureRenderable
|
|
332
|
+
// below fails every candidate and the component drops to the
|
|
333
|
+
// static fallback even though the sim could have run.
|
|
334
|
+
gl.getExtension("EXT_color_buffer_half_float");
|
|
335
|
+
const halfFloat = gl.getExtension("OES_texture_half_float");
|
|
336
|
+
supportLinearFiltering = !!gl.getExtension("OES_texture_half_float_linear");
|
|
337
|
+
halfFloatTexType = halfFloat ? halfFloat.HALF_FLOAT_OES : 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const velocityFormat = resolveFormat(gl, isWebGL2, "velocity", halfFloatTexType);
|
|
341
|
+
const scalarFormat = resolveFormat(gl, isWebGL2, "scalar", halfFloatTexType);
|
|
342
|
+
if (!velocityFormat || !scalarFormat) return null;
|
|
343
|
+
|
|
344
|
+
return { gl, ext: { isWebGL2, halfFloatTexType, supportLinearFiltering, velocityFormat, scalarFormat } };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function createBlit(gl: GL) {
|
|
348
|
+
const vertexBuffer = gl.createBuffer();
|
|
349
|
+
const indexBuffer = gl.createBuffer();
|
|
350
|
+
if (!vertexBuffer || !indexBuffer) throw new Error("LiquidText: unable to allocate geometry buffers");
|
|
351
|
+
|
|
352
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
|
353
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);
|
|
354
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
|
355
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);
|
|
356
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
357
|
+
gl.enableVertexAttribArray(0);
|
|
358
|
+
|
|
359
|
+
function blit(target: Fbo | null) {
|
|
360
|
+
if (target) {
|
|
361
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
|
|
362
|
+
gl.viewport(0, 0, target.width, target.height);
|
|
363
|
+
} else {
|
|
364
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
365
|
+
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
|
366
|
+
}
|
|
367
|
+
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function dispose() {
|
|
371
|
+
gl.deleteBuffer(vertexBuffer);
|
|
372
|
+
gl.deleteBuffer(indexBuffer);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { blit, dispose };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ===========================================================================
|
|
379
|
+
// Shader sources
|
|
380
|
+
// ===========================================================================
|
|
381
|
+
|
|
382
|
+
const VERTEX_SHADER = `
|
|
383
|
+
precision highp float;
|
|
384
|
+
attribute vec2 aPos;
|
|
385
|
+
varying vec2 vUv;
|
|
386
|
+
varying vec2 vL;
|
|
387
|
+
varying vec2 vR;
|
|
388
|
+
varying vec2 vT;
|
|
389
|
+
varying vec2 vB;
|
|
390
|
+
uniform vec2 texelSize;
|
|
391
|
+
|
|
392
|
+
void main () {
|
|
393
|
+
vUv = aPos * 0.5 + 0.5;
|
|
394
|
+
vL = vUv - vec2(texelSize.x, 0.0);
|
|
395
|
+
vR = vUv + vec2(texelSize.x, 0.0);
|
|
396
|
+
vT = vUv + vec2(0.0, texelSize.y);
|
|
397
|
+
vB = vUv - vec2(0.0, texelSize.y);
|
|
398
|
+
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
399
|
+
}
|
|
400
|
+
`;
|
|
401
|
+
|
|
402
|
+
const COPY_SHADER = `
|
|
403
|
+
precision mediump float;
|
|
404
|
+
precision mediump sampler2D;
|
|
405
|
+
varying vec2 vUv;
|
|
406
|
+
uniform sampler2D uTexture;
|
|
407
|
+
void main () {
|
|
408
|
+
gl_FragColor = texture2D(uTexture, vUv);
|
|
409
|
+
}
|
|
410
|
+
`;
|
|
411
|
+
|
|
412
|
+
// Self-advects the velocity field along its own flow, then applies the
|
|
413
|
+
// per-frame dissipation decay (a plain multiplicative decay — not a
|
|
414
|
+
// dt-scaled rate — so `dissipation` reads directly as "fraction of
|
|
415
|
+
// velocity kept per frame").
|
|
416
|
+
const ADVECTION_SHADER = `
|
|
417
|
+
precision highp float;
|
|
418
|
+
precision highp sampler2D;
|
|
419
|
+
varying vec2 vUv;
|
|
420
|
+
uniform sampler2D uVelocity;
|
|
421
|
+
uniform float dt;
|
|
422
|
+
uniform float dissipation;
|
|
423
|
+
|
|
424
|
+
void main () {
|
|
425
|
+
vec2 back = vUv - dt * texture2D(uVelocity, vUv).xy;
|
|
426
|
+
vec2 advected = texture2D(uVelocity, back).xy;
|
|
427
|
+
gl_FragColor = vec4(clamp(advected * dissipation, -4.0, 4.0), 0.0, 1.0);
|
|
428
|
+
}
|
|
429
|
+
`;
|
|
430
|
+
|
|
431
|
+
// Injects pointer force at `point` (UV) with a screen-px radius falloff:
|
|
432
|
+
// (1 - clamp(dist/radius, 0, 1))^2 * 1.5, dist measured in real screen px
|
|
433
|
+
// via `resolution` (the container's CSS size), independent of the sim's
|
|
434
|
+
// own (lower) texel resolution.
|
|
435
|
+
const SPLAT_SHADER = `
|
|
436
|
+
precision highp float;
|
|
437
|
+
precision highp sampler2D;
|
|
438
|
+
varying vec2 vUv;
|
|
439
|
+
uniform sampler2D uVelocity;
|
|
440
|
+
uniform vec2 point;
|
|
441
|
+
uniform vec2 resolution;
|
|
442
|
+
uniform float radius;
|
|
443
|
+
uniform vec2 force;
|
|
444
|
+
|
|
445
|
+
void main () {
|
|
446
|
+
vec2 diffPx = (vUv - point) * resolution;
|
|
447
|
+
float dist = length(diffPx);
|
|
448
|
+
float t = clamp(1.0 - dist / max(radius, 1.0), 0.0, 1.0);
|
|
449
|
+
float falloff = t * t * 1.5;
|
|
450
|
+
vec2 base = texture2D(uVelocity, vUv).xy;
|
|
451
|
+
gl_FragColor = vec4(clamp(base + force * falloff, -4.0, 4.0), 0.0, 1.0);
|
|
452
|
+
}
|
|
453
|
+
`;
|
|
454
|
+
|
|
455
|
+
// One Jacobi iteration of implicit viscous diffusion:
|
|
456
|
+
// x_new = (x0 + alpha*(xL+xR+xT+xB)) / (1 + 4*alpha), alpha = viscosity*dt.
|
|
457
|
+
// `uSource` is the fixed pre-diffusion snapshot (x0); `uVelocity` is the
|
|
458
|
+
// current iterate, ping-ponged across 8 calls.
|
|
459
|
+
const DIFFUSE_SHADER = `
|
|
460
|
+
precision highp float;
|
|
461
|
+
precision highp sampler2D;
|
|
462
|
+
varying vec2 vUv;
|
|
463
|
+
varying vec2 vL;
|
|
464
|
+
varying vec2 vR;
|
|
465
|
+
varying vec2 vT;
|
|
466
|
+
varying vec2 vB;
|
|
467
|
+
uniform sampler2D uVelocity;
|
|
468
|
+
uniform sampler2D uSource;
|
|
469
|
+
uniform float alpha;
|
|
470
|
+
|
|
471
|
+
void main () {
|
|
472
|
+
vec2 xL = texture2D(uVelocity, vL).xy;
|
|
473
|
+
vec2 xR = texture2D(uVelocity, vR).xy;
|
|
474
|
+
vec2 xT = texture2D(uVelocity, vT).xy;
|
|
475
|
+
vec2 xB = texture2D(uVelocity, vB).xy;
|
|
476
|
+
vec2 x0 = texture2D(uSource, vUv).xy;
|
|
477
|
+
float beta = 1.0 + 4.0 * alpha;
|
|
478
|
+
vec2 result = (x0 + alpha * (xL + xR + xT + xB)) / beta;
|
|
479
|
+
gl_FragColor = vec4(clamp(result, -4.0, 4.0), 0.0, 1.0);
|
|
480
|
+
}
|
|
481
|
+
`;
|
|
482
|
+
|
|
483
|
+
const DIVERGENCE_SHADER = `
|
|
484
|
+
precision highp float;
|
|
485
|
+
precision highp sampler2D;
|
|
486
|
+
varying vec2 vUv;
|
|
487
|
+
varying vec2 vL;
|
|
488
|
+
varying vec2 vR;
|
|
489
|
+
varying vec2 vT;
|
|
490
|
+
varying vec2 vB;
|
|
491
|
+
uniform sampler2D uVelocity;
|
|
492
|
+
|
|
493
|
+
void main () {
|
|
494
|
+
float L = texture2D(uVelocity, vL).x;
|
|
495
|
+
float R = texture2D(uVelocity, vR).x;
|
|
496
|
+
float T = texture2D(uVelocity, vT).y;
|
|
497
|
+
float B = texture2D(uVelocity, vB).y;
|
|
498
|
+
vec2 C = texture2D(uVelocity, vUv).xy;
|
|
499
|
+
if (vL.x < 0.0) L = -C.x;
|
|
500
|
+
if (vR.x > 1.0) R = -C.x;
|
|
501
|
+
if (vT.y > 1.0) T = -C.y;
|
|
502
|
+
if (vB.y < 0.0) B = -C.y;
|
|
503
|
+
float div = 0.5 * (R - L + T - B);
|
|
504
|
+
gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
|
|
505
|
+
}
|
|
506
|
+
`;
|
|
507
|
+
|
|
508
|
+
const PRESSURE_SHADER = `
|
|
509
|
+
precision highp float;
|
|
510
|
+
precision highp sampler2D;
|
|
511
|
+
varying vec2 vUv;
|
|
512
|
+
varying vec2 vL;
|
|
513
|
+
varying vec2 vR;
|
|
514
|
+
varying vec2 vT;
|
|
515
|
+
varying vec2 vB;
|
|
516
|
+
uniform sampler2D uPressure;
|
|
517
|
+
uniform sampler2D uDivergence;
|
|
518
|
+
|
|
519
|
+
void main () {
|
|
520
|
+
float L = texture2D(uPressure, vL).x;
|
|
521
|
+
float R = texture2D(uPressure, vR).x;
|
|
522
|
+
float T = texture2D(uPressure, vT).x;
|
|
523
|
+
float B = texture2D(uPressure, vB).x;
|
|
524
|
+
float div = texture2D(uDivergence, vUv).x;
|
|
525
|
+
float p = (L + R + T + B - div) * 0.25;
|
|
526
|
+
gl_FragColor = vec4(p, 0.0, 0.0, 1.0);
|
|
527
|
+
}
|
|
528
|
+
`;
|
|
529
|
+
|
|
530
|
+
const GRADIENT_SUBTRACT_SHADER = `
|
|
531
|
+
precision highp float;
|
|
532
|
+
precision highp sampler2D;
|
|
533
|
+
varying vec2 vUv;
|
|
534
|
+
varying vec2 vL;
|
|
535
|
+
varying vec2 vR;
|
|
536
|
+
varying vec2 vT;
|
|
537
|
+
varying vec2 vB;
|
|
538
|
+
uniform sampler2D uPressure;
|
|
539
|
+
uniform sampler2D uVelocity;
|
|
540
|
+
|
|
541
|
+
void main () {
|
|
542
|
+
float L = texture2D(uPressure, vL).x;
|
|
543
|
+
float R = texture2D(uPressure, vR).x;
|
|
544
|
+
float T = texture2D(uPressure, vT).x;
|
|
545
|
+
float B = texture2D(uPressure, vB).x;
|
|
546
|
+
vec2 vel = texture2D(uVelocity, vUv).xy;
|
|
547
|
+
vel -= 0.5 * vec2(R - L, T - B);
|
|
548
|
+
gl_FragColor = vec4(clamp(vel, -4.0, 4.0), 0.0, 1.0);
|
|
549
|
+
}
|
|
550
|
+
`;
|
|
551
|
+
|
|
552
|
+
// Final composite: warps the rasterized-text UV by the velocity field and
|
|
553
|
+
// takes 3 independently-offset taps (R/G/B) for the chromatic-aberration
|
|
554
|
+
// fringe. Alpha is the max of the 3 taps' alpha so the fringe itself is
|
|
555
|
+
// visible over the transparent background rather than being clipped to the
|
|
556
|
+
// unwarped glyph silhouette.
|
|
557
|
+
const DISPLAY_SHADER = `
|
|
558
|
+
precision highp float;
|
|
559
|
+
precision highp sampler2D;
|
|
560
|
+
varying vec2 vUv;
|
|
561
|
+
uniform sampler2D uText;
|
|
562
|
+
uniform sampler2D uVelocity;
|
|
563
|
+
uniform float strength;
|
|
564
|
+
uniform float chromaticRatio;
|
|
565
|
+
|
|
566
|
+
void main () {
|
|
567
|
+
vec2 vel = texture2D(uVelocity, vUv).xy;
|
|
568
|
+
vec2 warp = vel * strength;
|
|
569
|
+
vec2 chroma = warp * chromaticRatio;
|
|
570
|
+
vec2 base = vUv - warp;
|
|
571
|
+
vec4 rTap = texture2D(uText, base - chroma);
|
|
572
|
+
vec4 gTap = texture2D(uText, base);
|
|
573
|
+
vec4 bTap = texture2D(uText, base + chroma);
|
|
574
|
+
vec3 color = vec3(rTap.r, gTap.g, bTap.b);
|
|
575
|
+
float a = max(rTap.a, max(gTap.a, bTap.a));
|
|
576
|
+
gl_FragColor = vec4(color, a);
|
|
577
|
+
}
|
|
578
|
+
`;
|
|
579
|
+
|
|
580
|
+
const FIXED_DT = 0.016;
|
|
581
|
+
const DIFFUSE_ITERATIONS = 8;
|
|
582
|
+
const PRESSURE_ITERATIONS = 16;
|
|
583
|
+
const SIM_SCALE = 0.25;
|
|
584
|
+
|
|
585
|
+
// ===========================================================================
|
|
586
|
+
// Component
|
|
587
|
+
// ===========================================================================
|
|
588
|
+
|
|
589
|
+
let {
|
|
590
|
+
text = "Liquid",
|
|
591
|
+
font = "",
|
|
592
|
+
fontSize = 0,
|
|
593
|
+
fontWeight = 700,
|
|
594
|
+
lightColor = "#000000",
|
|
595
|
+
darkColor = "#ffffff",
|
|
596
|
+
class: className = "",
|
|
597
|
+
strength = 0.5,
|
|
598
|
+
radius = 160,
|
|
599
|
+
forceGain = 17,
|
|
600
|
+
dissipation = 0.98,
|
|
601
|
+
viscosity = 4,
|
|
602
|
+
chromaticRatio = 0.2,
|
|
603
|
+
staticBelow = 1024,
|
|
604
|
+
interactive = true,
|
|
605
|
+
pauseWhenHidden = true,
|
|
606
|
+
}: LiquidTextProps = $props();
|
|
607
|
+
|
|
608
|
+
let rootEl: HTMLDivElement | undefined = $state();
|
|
609
|
+
let canvasEl: HTMLCanvasElement | undefined = $state();
|
|
610
|
+
|
|
611
|
+
// Always starts "static" so the very first render (SSR or client) never
|
|
612
|
+
// touches window/document — onMount promotes to "canvas" if eligible.
|
|
613
|
+
let mode: "static" | "canvas" = $state("static");
|
|
614
|
+
let themeColor = $state(lightColor);
|
|
615
|
+
let resolvedFont = $state(font);
|
|
616
|
+
let displayFontSize = $state(fontSize > 0 ? fontSize : 64);
|
|
617
|
+
|
|
618
|
+
// Hoisted so the prop-reactivity effect below can push a re-rasterize
|
|
619
|
+
// into the live WebGL texture after mount. Assigned by setupCanvasSim
|
|
620
|
+
// once the sim is running; stays null on the static-fallback path, where
|
|
621
|
+
// updating the $state above is enough since the fallback is just
|
|
622
|
+
// reactive DOM markup.
|
|
623
|
+
let rasterize: (() => void) | null = null;
|
|
624
|
+
|
|
625
|
+
const rootHeight = $derived(`${Math.round(displayFontSize * 1.2)}px`);
|
|
626
|
+
|
|
627
|
+
function activeThemeColor(): string {
|
|
628
|
+
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
|
629
|
+
? darkColor
|
|
630
|
+
: lightColor;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Recomputes resolvedFont + displayFontSize together (using a local
|
|
634
|
+
// `family` const rather than re-reading the `resolvedFont` state it just
|
|
635
|
+
// wrote) so this can safely run inside the reactive $effect below without
|
|
636
|
+
// the effect both writing and re-reading the same piece of state in one
|
|
637
|
+
// pass.
|
|
638
|
+
function fitFontSize() {
|
|
639
|
+
if (!rootEl) return;
|
|
640
|
+
const family = font || getComputedStyle(rootEl).fontFamily || "sans-serif";
|
|
641
|
+
resolvedFont = family;
|
|
642
|
+
if (fontSize > 0) {
|
|
643
|
+
displayFontSize = fontSize;
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const containerWidth = rootEl.clientWidth || 1;
|
|
647
|
+
const probe = document.createElement("canvas").getContext("2d");
|
|
648
|
+
if (!probe) return;
|
|
649
|
+
const reference = 100;
|
|
650
|
+
probe.font = `${fontWeight} ${reference}px ${family}`;
|
|
651
|
+
const measured = probe.measureText(text).width || 1;
|
|
652
|
+
const fitted = reference * (containerWidth / measured);
|
|
653
|
+
displayFontSize = Math.min(Math.max(fitted, 8), 2000);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Per the "text/font changes re-rasterize" requirement: the WebGL path
|
|
657
|
+
// otherwise only reads text/font/fontSize/fontWeight/lightColor/
|
|
658
|
+
// darkColor once, at setup, so updating them in e.g. a Storybook/docs
|
|
659
|
+
// control would silently do nothing. Reading the props directly here
|
|
660
|
+
// (rather than only inside the helpers above) registers them as this
|
|
661
|
+
// effect's dependencies, so any of them changing after mount re-derives
|
|
662
|
+
// the resolved font/size/color and, on the canvas path, re-rasterizes.
|
|
663
|
+
$effect(() => {
|
|
664
|
+
void text;
|
|
665
|
+
void font;
|
|
666
|
+
void fontSize;
|
|
667
|
+
void fontWeight;
|
|
668
|
+
void lightColor;
|
|
669
|
+
void darkColor;
|
|
670
|
+
if (!rootEl) return;
|
|
671
|
+
themeColor = activeThemeColor();
|
|
672
|
+
fitFontSize();
|
|
673
|
+
rasterize?.();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
onMount(() => {
|
|
677
|
+
const root = rootEl;
|
|
678
|
+
const canvas = canvasEl;
|
|
679
|
+
if (!root || !canvas) return;
|
|
680
|
+
|
|
681
|
+
themeColor = activeThemeColor();
|
|
682
|
+
|
|
683
|
+
const themeObserver = new MutationObserver(() => {
|
|
684
|
+
const next = activeThemeColor();
|
|
685
|
+
if (next !== themeColor) {
|
|
686
|
+
themeColor = next;
|
|
687
|
+
rasterize?.();
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
themeObserver.observe(document.documentElement, {
|
|
691
|
+
attributes: true,
|
|
692
|
+
attributeFilter: ["class"],
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
fitFontSize();
|
|
696
|
+
|
|
697
|
+
const reduceMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
698
|
+
const tooSmall = window.innerWidth <= staticBelow;
|
|
699
|
+
|
|
700
|
+
let cleanupCanvasSim: (() => void) | null = null;
|
|
701
|
+
let fallbackResizeObserver: ResizeObserver | null = null;
|
|
702
|
+
|
|
703
|
+
// setupCanvasSim installs its own ResizeObserver; the static fallback
|
|
704
|
+
// (reduced-motion, staticBelow, no WebGL, a failed GL init, or a
|
|
705
|
+
// mid-session GPU context loss — see onContextLost inside
|
|
706
|
+
// setupCanvasSim) has no such observer of its own, so without this the
|
|
707
|
+
// auto-fit (fontSize=0) fallback text would stay frozen at its
|
|
708
|
+
// mount-time size across any later container/viewport resize (e.g.
|
|
709
|
+
// mobile rotation). Guarded against re-entry so it's safe to call
|
|
710
|
+
// again (e.g. if a restored context is lost a second time) without
|
|
711
|
+
// leaking a duplicate observer.
|
|
712
|
+
function startFallbackResize() {
|
|
713
|
+
if (!root || fallbackResizeObserver) return;
|
|
714
|
+
fallbackResizeObserver = new ResizeObserver(() => fitFontSize());
|
|
715
|
+
fallbackResizeObserver.observe(root);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (!reduceMotionQuery.matches && !tooSmall) {
|
|
719
|
+
cleanupCanvasSim = setupCanvasSim(canvas, root, startFallbackResize);
|
|
720
|
+
}
|
|
721
|
+
if (!cleanupCanvasSim) {
|
|
722
|
+
startFallbackResize();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Honor a live toggle of the OS reduced-motion setting, not just its
|
|
726
|
+
// value at mount: if it flips to "reduce" while the sim is running,
|
|
727
|
+
// tear the sim down and drop to the static fallback.
|
|
728
|
+
function onReduceMotionChange(e: MediaQueryListEvent) {
|
|
729
|
+
if (e.matches && cleanupCanvasSim) {
|
|
730
|
+
cleanupCanvasSim();
|
|
731
|
+
cleanupCanvasSim = null;
|
|
732
|
+
mode = "static";
|
|
733
|
+
startFallbackResize();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
reduceMotionQuery.addEventListener("change", onReduceMotionChange);
|
|
737
|
+
|
|
738
|
+
return () => {
|
|
739
|
+
themeObserver.disconnect();
|
|
740
|
+
reduceMotionQuery.removeEventListener("change", onReduceMotionChange);
|
|
741
|
+
fallbackResizeObserver?.disconnect();
|
|
742
|
+
cleanupCanvasSim?.();
|
|
743
|
+
};
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Attempts to acquire a WebGL context and, if successful, boots the fluid
|
|
748
|
+
* sim + render loop and switches `mode` to "canvas". Returns a cleanup
|
|
749
|
+
* function on success, or `null` if WebGL is unavailable (caller should
|
|
750
|
+
* keep the static DOM fallback).
|
|
751
|
+
*
|
|
752
|
+
* `startFallbackResize` is onMount's fallback-resize starter, threaded
|
|
753
|
+
* through so the context-loss handler below can re-arm it when it drops
|
|
754
|
+
* the sim to the static fallback mid-session — it closes over onMount's
|
|
755
|
+
* `root`/`fallbackResizeObserver`, so calling it here keeps that observer
|
|
756
|
+
* reachable from onMount's own cleanup return, and it's a no-op if the
|
|
757
|
+
* observer is already attached.
|
|
758
|
+
*/
|
|
759
|
+
function setupCanvasSim(
|
|
760
|
+
canvas: HTMLCanvasElement,
|
|
761
|
+
root: HTMLDivElement,
|
|
762
|
+
startFallbackResize: () => void
|
|
763
|
+
): (() => void) | null {
|
|
764
|
+
const acquired = acquireGL(canvas);
|
|
765
|
+
if (!acquired) return null;
|
|
766
|
+
|
|
767
|
+
const { gl, ext } = acquired;
|
|
768
|
+
|
|
769
|
+
// Declared here (outside the try below) so both the success path and
|
|
770
|
+
// the failure-recovery catch block can safely reference them — `let`/
|
|
771
|
+
// `function` declared inside a `try { … }` are not visible from its
|
|
772
|
+
// `catch { … }`, since try/catch are separate block scopes.
|
|
773
|
+
let velocity: DoubleFbo | null = null;
|
|
774
|
+
let divergenceFbo: Fbo | null = null;
|
|
775
|
+
let pressure: DoubleFbo | null = null;
|
|
776
|
+
let diffuseSource: Fbo | null = null;
|
|
777
|
+
let textTexture: WebGLTexture | null = null;
|
|
778
|
+
let textCanvas: HTMLCanvasElement | null = null;
|
|
779
|
+
|
|
780
|
+
function destroySimBuffers() {
|
|
781
|
+
deleteDoubleFbo(gl, velocity);
|
|
782
|
+
deleteFbo(gl, divergenceFbo);
|
|
783
|
+
deleteDoubleFbo(gl, pressure);
|
|
784
|
+
deleteFbo(gl, diffuseSource);
|
|
785
|
+
velocity = null;
|
|
786
|
+
divergenceFbo = null;
|
|
787
|
+
pressure = null;
|
|
788
|
+
diffuseSource = null;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Tracks GL resources as they're successfully allocated during this
|
|
792
|
+
// synchronous setup pass, so a later failure (a program that fails to
|
|
793
|
+
// compile/link, exhausted buffers, …) can free everything created
|
|
794
|
+
// before it instead of stranding those objects on the canvas's
|
|
795
|
+
// still-live GL context. Cleared once setup fully succeeds.
|
|
796
|
+
const disposeOnFailure: Array<() => void> = [];
|
|
797
|
+
|
|
798
|
+
try {
|
|
799
|
+
const { blit, dispose: disposeGeometry } = createBlit(gl);
|
|
800
|
+
disposeOnFailure.push(disposeGeometry);
|
|
801
|
+
|
|
802
|
+
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
|
|
803
|
+
disposeOnFailure.push(() => gl.deleteShader(vertexShader));
|
|
804
|
+
|
|
805
|
+
function makeProgram(fragmentSource: string): GlProgram {
|
|
806
|
+
const program = new GlProgram(gl, vertexShader, fragmentSource);
|
|
807
|
+
disposeOnFailure.push(() => program.dispose());
|
|
808
|
+
return program;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const advectionProgram = makeProgram(ADVECTION_SHADER);
|
|
812
|
+
const splatProgram = makeProgram(SPLAT_SHADER);
|
|
813
|
+
const diffuseProgram = makeProgram(DIFFUSE_SHADER);
|
|
814
|
+
const divergenceProgram = makeProgram(DIVERGENCE_SHADER);
|
|
815
|
+
const pressureProgram = makeProgram(PRESSURE_SHADER);
|
|
816
|
+
const gradientSubtractProgram = makeProgram(GRADIENT_SUBTRACT_SHADER);
|
|
817
|
+
const copyProgram = makeProgram(COPY_SHADER);
|
|
818
|
+
const displayProgram = makeProgram(DISPLAY_SHADER);
|
|
819
|
+
gl.deleteShader(vertexShader);
|
|
820
|
+
|
|
821
|
+
const filter = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
|
|
822
|
+
|
|
823
|
+
let cssWidth = 0;
|
|
824
|
+
let cssHeight = 0;
|
|
825
|
+
|
|
826
|
+
function createSimBuffers(simW: number, simH: number) {
|
|
827
|
+
velocity = createDoubleFbo(gl, simW, simH, ext.velocityFormat, ext.halfFloatTexType, filter);
|
|
828
|
+
divergenceFbo = createFbo(gl, simW, simH, ext.scalarFormat, ext.halfFloatTexType, gl.NEAREST);
|
|
829
|
+
pressure = createDoubleFbo(gl, simW, simH, ext.scalarFormat, ext.halfFloatTexType, gl.NEAREST);
|
|
830
|
+
diffuseSource = createFbo(gl, simW, simH, ext.velocityFormat, ext.halfFloatTexType, filter);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function rasterizeText() {
|
|
834
|
+
const dpr = window.devicePixelRatio || 1;
|
|
835
|
+
const w = Math.max(1, Math.round(cssWidth * dpr));
|
|
836
|
+
const h = Math.max(1, Math.round(cssHeight * dpr));
|
|
837
|
+
if (!textCanvas) textCanvas = document.createElement("canvas");
|
|
838
|
+
textCanvas.width = w;
|
|
839
|
+
textCanvas.height = h;
|
|
840
|
+
const ctx = textCanvas.getContext("2d");
|
|
841
|
+
if (!ctx) return;
|
|
842
|
+
ctx.clearRect(0, 0, w, h);
|
|
843
|
+
ctx.font = `${fontWeight} ${displayFontSize * dpr}px ${resolvedFont}`;
|
|
844
|
+
ctx.fillStyle = themeColor;
|
|
845
|
+
ctx.textAlign = "left";
|
|
846
|
+
ctx.textBaseline = "middle";
|
|
847
|
+
ctx.fillText(text, 0, h / 2);
|
|
848
|
+
|
|
849
|
+
if (!textTexture) textTexture = gl.createTexture();
|
|
850
|
+
gl.bindTexture(gl.TEXTURE_2D, textTexture);
|
|
851
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
852
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
853
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
854
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
855
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
856
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textCanvas);
|
|
857
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function resizeAll() {
|
|
861
|
+
fitFontSize();
|
|
862
|
+
const dpr = window.devicePixelRatio || 1;
|
|
863
|
+
cssWidth = root.clientWidth || 1;
|
|
864
|
+
cssHeight = Math.round(displayFontSize * 1.2) || 1;
|
|
865
|
+
const pxW = Math.max(1, Math.round(cssWidth * dpr));
|
|
866
|
+
const pxH = Math.max(1, Math.round(cssHeight * dpr));
|
|
867
|
+
if (canvas.width !== pxW || canvas.height !== pxH) {
|
|
868
|
+
canvas.width = pxW;
|
|
869
|
+
canvas.height = pxH;
|
|
870
|
+
}
|
|
871
|
+
const simW = Math.max(1, Math.round(pxW * SIM_SCALE));
|
|
872
|
+
const simH = Math.max(1, Math.round(pxH * SIM_SCALE));
|
|
873
|
+
if (!velocity || velocity.width !== simW || velocity.height !== simH) {
|
|
874
|
+
destroySimBuffers();
|
|
875
|
+
createSimBuffers(simW, simH);
|
|
876
|
+
}
|
|
877
|
+
rasterizeText();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
resizeAll();
|
|
881
|
+
mode = "canvas";
|
|
882
|
+
rasterize = rasterizeText;
|
|
883
|
+
|
|
884
|
+
const resizeObserver = new ResizeObserver(() => resizeAll());
|
|
885
|
+
resizeObserver.observe(root);
|
|
886
|
+
|
|
887
|
+
// --- pointer tracking (UV space, y=1 at top to match vUv) ---
|
|
888
|
+
const pointerUV = { x: 0.5, y: 0.5 };
|
|
889
|
+
const pointerPrevUV = { x: 0.5, y: 0.5 };
|
|
890
|
+
const pointerForce = { x: 0, y: 0 };
|
|
891
|
+
let pointerHasPrev = false;
|
|
892
|
+
let pointerMoved = false;
|
|
893
|
+
|
|
894
|
+
function onPointerMove(e: PointerEvent) {
|
|
895
|
+
if (!interactive) return;
|
|
896
|
+
const rect = root.getBoundingClientRect();
|
|
897
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
898
|
+
const x = (e.clientX - rect.left) / rect.width;
|
|
899
|
+
const y = 1 - (e.clientY - rect.top) / rect.height;
|
|
900
|
+
if (pointerHasPrev) {
|
|
901
|
+
pointerForce.x = (x - pointerPrevUV.x) * forceGain;
|
|
902
|
+
pointerForce.y = (y - pointerPrevUV.y) * forceGain;
|
|
903
|
+
pointerMoved = true;
|
|
904
|
+
}
|
|
905
|
+
pointerPrevUV.x = x;
|
|
906
|
+
pointerPrevUV.y = y;
|
|
907
|
+
pointerUV.x = x;
|
|
908
|
+
pointerUV.y = y;
|
|
909
|
+
pointerHasPrev = true;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Always registered — `interactive` is read live inside
|
|
913
|
+
// onPointerMove above, so toggling the prop after mount (e.g. a
|
|
914
|
+
// Storybook/docs control) takes effect immediately instead of
|
|
915
|
+
// only reflecting its value at initial setup.
|
|
916
|
+
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
|
917
|
+
|
|
918
|
+
// --- visibility pause ---
|
|
919
|
+
let hidden = document.hidden;
|
|
920
|
+
function onVisibilityChange() {
|
|
921
|
+
hidden = document.hidden;
|
|
922
|
+
}
|
|
923
|
+
// Always registered — `pauseWhenHidden` is read live in frame()
|
|
924
|
+
// below, so toggling the prop after mount takes effect immediately
|
|
925
|
+
// instead of only reflecting its value at initial setup.
|
|
926
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
927
|
+
|
|
928
|
+
// --- sim passes ---
|
|
929
|
+
function applySplat() {
|
|
930
|
+
if (!pointerMoved || !velocity) return;
|
|
931
|
+
pointerMoved = false;
|
|
932
|
+
splatProgram.use();
|
|
933
|
+
splatProgram.set2f("point", pointerUV.x, pointerUV.y);
|
|
934
|
+
splatProgram.set2f("resolution", cssWidth, cssHeight);
|
|
935
|
+
splatProgram.set1f("radius", radius);
|
|
936
|
+
splatProgram.set2f("force", pointerForce.x, pointerForce.y);
|
|
937
|
+
splatProgram.set1i("uVelocity", velocity.read.attach(0));
|
|
938
|
+
blit(velocity.write);
|
|
939
|
+
velocity.swap();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function diffuseVelocity(dt: number) {
|
|
943
|
+
if (!velocity || !diffuseSource) return;
|
|
944
|
+
copyProgram.use();
|
|
945
|
+
copyProgram.set1i("uTexture", velocity.read.attach(0));
|
|
946
|
+
blit(diffuseSource);
|
|
947
|
+
|
|
948
|
+
const alpha = viscosity * dt;
|
|
949
|
+
for (let i = 0; i < DIFFUSE_ITERATIONS; i++) {
|
|
950
|
+
diffuseProgram.use();
|
|
951
|
+
diffuseProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
|
|
952
|
+
diffuseProgram.set1i("uVelocity", velocity.read.attach(0));
|
|
953
|
+
diffuseProgram.set1i("uSource", diffuseSource.attach(1));
|
|
954
|
+
diffuseProgram.set1f("alpha", alpha);
|
|
955
|
+
blit(velocity.write);
|
|
956
|
+
velocity.swap();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function clearFbo(fbo: Fbo) {
|
|
961
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.framebuffer);
|
|
962
|
+
gl.viewport(0, 0, fbo.width, fbo.height);
|
|
963
|
+
gl.clearColor(0, 0, 0, 0);
|
|
964
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function step(dt: number) {
|
|
968
|
+
if (!velocity || !divergenceFbo || !pressure) return;
|
|
969
|
+
gl.disable(gl.BLEND);
|
|
970
|
+
|
|
971
|
+
advectionProgram.use();
|
|
972
|
+
advectionProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
|
|
973
|
+
advectionProgram.set1i("uVelocity", velocity.read.attach(0));
|
|
974
|
+
advectionProgram.set1f("dt", dt);
|
|
975
|
+
advectionProgram.set1f("dissipation", dissipation);
|
|
976
|
+
blit(velocity.write);
|
|
977
|
+
velocity.swap();
|
|
978
|
+
|
|
979
|
+
applySplat();
|
|
980
|
+
diffuseVelocity(dt);
|
|
981
|
+
|
|
982
|
+
divergenceProgram.use();
|
|
983
|
+
divergenceProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
|
|
984
|
+
divergenceProgram.set1i("uVelocity", velocity.read.attach(0));
|
|
985
|
+
blit(divergenceFbo);
|
|
986
|
+
|
|
987
|
+
clearFbo(pressure.write);
|
|
988
|
+
pressure.swap();
|
|
989
|
+
for (let i = 0; i < PRESSURE_ITERATIONS; i++) {
|
|
990
|
+
pressureProgram.use();
|
|
991
|
+
pressureProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
|
|
992
|
+
pressureProgram.set1i("uPressure", pressure.read.attach(0));
|
|
993
|
+
pressureProgram.set1i("uDivergence", divergenceFbo.attach(1));
|
|
994
|
+
blit(pressure.write);
|
|
995
|
+
pressure.swap();
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
gradientSubtractProgram.use();
|
|
999
|
+
gradientSubtractProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
|
|
1000
|
+
gradientSubtractProgram.set1i("uPressure", pressure.read.attach(0));
|
|
1001
|
+
gradientSubtractProgram.set1i("uVelocity", velocity.read.attach(1));
|
|
1002
|
+
blit(velocity.write);
|
|
1003
|
+
velocity.swap();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function draw() {
|
|
1007
|
+
if (!velocity || !textTexture) return;
|
|
1008
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1009
|
+
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
|
1010
|
+
gl.clearColor(0, 0, 0, 0);
|
|
1011
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
1012
|
+
gl.enable(gl.BLEND);
|
|
1013
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
1014
|
+
|
|
1015
|
+
displayProgram.use();
|
|
1016
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1017
|
+
gl.bindTexture(gl.TEXTURE_2D, textTexture);
|
|
1018
|
+
displayProgram.set1i("uText", 0);
|
|
1019
|
+
displayProgram.set1i("uVelocity", velocity.read.attach(1));
|
|
1020
|
+
displayProgram.set1f("strength", strength);
|
|
1021
|
+
displayProgram.set1f("chromaticRatio", chromaticRatio);
|
|
1022
|
+
blit(null);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
let rafId: number | null = requestAnimationFrame(frame);
|
|
1026
|
+
function frame() {
|
|
1027
|
+
rafId = requestAnimationFrame(frame);
|
|
1028
|
+
if (pauseWhenHidden && hidden) return;
|
|
1029
|
+
step(FIXED_DT);
|
|
1030
|
+
draw();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Tears down the running sim's listeners/GL resources. Shared by
|
|
1034
|
+
// the context-loss handler below (which must stop issuing calls
|
|
1035
|
+
// against a dead context) and the normal unmount cleanup.
|
|
1036
|
+
function teardownSim() {
|
|
1037
|
+
if (rafId !== null) {
|
|
1038
|
+
cancelAnimationFrame(rafId);
|
|
1039
|
+
rafId = null;
|
|
1040
|
+
}
|
|
1041
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
1042
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
1043
|
+
resizeObserver.disconnect();
|
|
1044
|
+
destroySimBuffers();
|
|
1045
|
+
if (textTexture) {
|
|
1046
|
+
gl.deleteTexture(textTexture);
|
|
1047
|
+
textTexture = null;
|
|
1048
|
+
}
|
|
1049
|
+
for (const p of [
|
|
1050
|
+
advectionProgram,
|
|
1051
|
+
splatProgram,
|
|
1052
|
+
diffuseProgram,
|
|
1053
|
+
divergenceProgram,
|
|
1054
|
+
pressureProgram,
|
|
1055
|
+
gradientSubtractProgram,
|
|
1056
|
+
copyProgram,
|
|
1057
|
+
displayProgram,
|
|
1058
|
+
]) {
|
|
1059
|
+
p.dispose();
|
|
1060
|
+
}
|
|
1061
|
+
disposeGeometry();
|
|
1062
|
+
rasterize = null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// A real GPU context loss (driver reset, GPU switch, too many live
|
|
1066
|
+
// contexts, …) must not leave the rAF loop silently issuing calls
|
|
1067
|
+
// against a dead context, and — without preventDefault() — the
|
|
1068
|
+
// context would never become eligible for restoration. Recovery
|
|
1069
|
+
// here is deliberately conservative: every GL object created
|
|
1070
|
+
// above is invalidated by the loss, so rather than rebuilding the
|
|
1071
|
+
// whole sim in place this drops to the stable static DOM fallback
|
|
1072
|
+
// for the remainder of this mount.
|
|
1073
|
+
function onContextLost(event: Event) {
|
|
1074
|
+
event.preventDefault();
|
|
1075
|
+
teardownSim();
|
|
1076
|
+
mode = "static";
|
|
1077
|
+
startFallbackResize();
|
|
1078
|
+
}
|
|
1079
|
+
function onContextRestored() {
|
|
1080
|
+
// Intentionally inert — see onContextLost above. The static
|
|
1081
|
+
// fallback stays in place rather than attempting a risky
|
|
1082
|
+
// partial rebuild of programs/geometry/FBOs in place.
|
|
1083
|
+
}
|
|
1084
|
+
canvas.addEventListener("webglcontextlost", onContextLost, false);
|
|
1085
|
+
canvas.addEventListener("webglcontextrestored", onContextRestored, false);
|
|
1086
|
+
|
|
1087
|
+
disposeOnFailure.length = 0;
|
|
1088
|
+
|
|
1089
|
+
return () => {
|
|
1090
|
+
canvas.removeEventListener("webglcontextlost", onContextLost);
|
|
1091
|
+
canvas.removeEventListener("webglcontextrestored", onContextRestored);
|
|
1092
|
+
teardownSim();
|
|
1093
|
+
const loseContext = gl.getExtension("WEBGL_lose_context");
|
|
1094
|
+
loseContext?.loseContext();
|
|
1095
|
+
};
|
|
1096
|
+
} catch {
|
|
1097
|
+
// Best-effort cleanup of whatever GL resources were already
|
|
1098
|
+
// allocated before the failure (a program compile/link error,
|
|
1099
|
+
// exhausted geometry buffers, a texture allocation failure inside
|
|
1100
|
+
// the initial resizeAll(), …), so a failed init doesn't strand
|
|
1101
|
+
// them on the canvas's still-live context. The disposeOnFailure
|
|
1102
|
+
// entries are each individually guarded since how far setup got
|
|
1103
|
+
// before throwing determines which of them actually ran; the sim
|
|
1104
|
+
// buffers/texture are unconditionally declared above (outside
|
|
1105
|
+
// this try), so they're always safe to tear down here.
|
|
1106
|
+
for (const dispose of disposeOnFailure) {
|
|
1107
|
+
try {
|
|
1108
|
+
dispose();
|
|
1109
|
+
} catch {
|
|
1110
|
+
/* already gone, or the context is no longer usable */
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
destroySimBuffers();
|
|
1114
|
+
if (textTexture) gl.deleteTexture(textTexture);
|
|
1115
|
+
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
1116
|
+
// Falls back to the static DOM text path rather than throwing.
|
|
1117
|
+
mode = "static";
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
</script>
|
|
1122
|
+
|
|
1123
|
+
<div bind:this={rootEl} class={cn("liquid-text relative block w-full", className)} style:height={rootHeight}>
|
|
1124
|
+
<canvas
|
|
1125
|
+
bind:this={canvasEl}
|
|
1126
|
+
aria-hidden="true"
|
|
1127
|
+
class={cn("pointer-events-none absolute inset-0 block h-full w-full", mode === "canvas" ? "" : "invisible")}
|
|
1128
|
+
></canvas>
|
|
1129
|
+
{#if mode === "canvas"}
|
|
1130
|
+
<span class="sr-only">{text}</span>
|
|
1131
|
+
{:else}
|
|
1132
|
+
<span
|
|
1133
|
+
class="liquid-text-fallback block"
|
|
1134
|
+
style:color={themeColor}
|
|
1135
|
+
style:font-family={resolvedFont}
|
|
1136
|
+
style:font-size={`${displayFontSize}px`}
|
|
1137
|
+
style:font-weight={fontWeight}
|
|
1138
|
+
>{text}</span>
|
|
1139
|
+
{/if}
|
|
1140
|
+
</div>
|
|
1141
|
+
|
|
1142
|
+
<style>
|
|
1143
|
+
.liquid-text {
|
|
1144
|
+
line-height: 1;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
.liquid-text-fallback {
|
|
1148
|
+
margin: 0;
|
|
1149
|
+
white-space: nowrap;
|
|
1150
|
+
}
|
|
1151
|
+
</style>
|