@shulya/liquid-glass 1.0.0 → 1.0.1
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 +2 -0
- package/dist/core.cjs +17 -10
- package/dist/core.mjs +17 -10
- package/example/README.md +1 -0
- package/example/src/App.jsx +96 -51
- package/example/src/styles.css +6 -3
- package/package.json +1 -2
- package/src/core.js +17 -10
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
**`@shulya/liquid-glass`** is an ultra-lightweight, zero-WebGL optical glass rendering engine for modern web applications. Turn any HTML container, card, navigation bar, modal, or interactive widget into a realistic refractive **Liquid Glass** block with real-time control over **`rim`**, **`ior`**, **`height`**, **`frosted`**, and **`fusion`** parameters.
|
|
11
11
|
|
|
12
12
|
- **Live Interactive Demo:** [https://liquid.shulya.tech](https://liquid.shulya.tech)
|
|
13
|
+
- **NPM Package:** [https://www.npmjs.com/package/@shulya/liquid-glass](https://www.npmjs.com/package/@shulya/liquid-glass)
|
|
13
14
|
- **GitHub Repository:** [https://github.com/shulya-tech/liquid-glass](https://github.com/shulya-tech/liquid-glass)
|
|
14
15
|
|
|
15
16
|

|
|
@@ -327,6 +328,7 @@ To guarantee zero runtime trigonometric or shader overhead across mobile devices
|
|
|
327
328
|
## Links
|
|
328
329
|
|
|
329
330
|
- **Live Demo:** [https://liquid.shulya.tech](https://liquid.shulya.tech)
|
|
331
|
+
- **NPM Package:** [https://www.npmjs.com/package/@shulya/liquid-glass](https://www.npmjs.com/package/@shulya/liquid-glass)
|
|
330
332
|
- **GitHub Repository:** [https://github.com/shulya-tech/liquid-glass](https://github.com/shulya-tech/liquid-glass)
|
|
331
333
|
- **Issue Tracker:** [https://github.com/shulya-tech/liquid-glass/issues](https://github.com/shulya-tech/liquid-glass/issues)
|
|
332
334
|
|
package/dist/core.cjs
CHANGED
|
@@ -399,6 +399,7 @@ let blurCtx = null;
|
|
|
399
399
|
let clusterOverlayCanvas = null;
|
|
400
400
|
let clusterOverlayCtx = null;
|
|
401
401
|
let rafScheduled = false;
|
|
402
|
+
let rafId = 0;
|
|
402
403
|
let listenersAttached = false;
|
|
403
404
|
let lastFrostedRadius = -1;
|
|
404
405
|
|
|
@@ -449,8 +450,9 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
|
|
|
449
450
|
|
|
450
451
|
function ensureStageBuffers() {
|
|
451
452
|
if (typeof document === 'undefined') return false;
|
|
452
|
-
const
|
|
453
|
-
const
|
|
453
|
+
const docEl = document.documentElement;
|
|
454
|
+
const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
|
|
455
|
+
const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
|
|
454
456
|
|
|
455
457
|
if (!stageCanvas) {
|
|
456
458
|
stageCanvas = document.createElement('canvas');
|
|
@@ -497,13 +499,16 @@ function captureViewportStage(neededBlur) {
|
|
|
497
499
|
stageCtx.fillStyle = '#040814';
|
|
498
500
|
stageCtx.fillRect(0, 0, vw, vh);
|
|
499
501
|
|
|
500
|
-
// 2. Copy any background 2D canvas
|
|
502
|
+
// 2. Copy any background 2D canvas using its exact getBoundingClientRect() viewport box
|
|
501
503
|
const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
|
|
502
504
|
for (let i = 0; i < bgCanvases.length; i++) {
|
|
503
505
|
const c = bgCanvases[i];
|
|
504
506
|
if (c.width > 0 && c.height > 0) {
|
|
505
507
|
try {
|
|
506
|
-
|
|
508
|
+
const cr = c.getBoundingClientRect();
|
|
509
|
+
if (cr.width > 0 && cr.height > 0) {
|
|
510
|
+
stageCtx.drawImage(c, cr.left, cr.top, cr.width, cr.height);
|
|
511
|
+
}
|
|
507
512
|
} catch {
|
|
508
513
|
// Ignore cross-origin canvas if any
|
|
509
514
|
}
|
|
@@ -696,10 +701,9 @@ function renderIsolatedController(ctrl, rect) {
|
|
|
696
701
|
ctrl._canvas.height = h;
|
|
697
702
|
}
|
|
698
703
|
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
const
|
|
702
|
-
const dy = Math.max(0, Math.min(vh - h, Math.round(rect.top)));
|
|
704
|
+
// Use exact un-clamped element position so dragging near/past mobile viewport edges never shifts the image
|
|
705
|
+
const dx = Math.round(rect.left);
|
|
706
|
+
const dy = Math.round(rect.top);
|
|
703
707
|
|
|
704
708
|
const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
|
|
705
709
|
const srcImg = sourceCtx.getImageData(dx, dy, w, h);
|
|
@@ -914,9 +918,12 @@ function renderAllActiveLenses() {
|
|
|
914
918
|
}
|
|
915
919
|
|
|
916
920
|
exports.scheduleStageRedraw = function scheduleStageRedraw() {
|
|
917
|
-
if (
|
|
921
|
+
if (typeof window === 'undefined') return;
|
|
922
|
+
if (rafScheduled && rafId) {
|
|
923
|
+
window.cancelAnimationFrame(rafId);
|
|
924
|
+
}
|
|
918
925
|
rafScheduled = true;
|
|
919
|
-
window.requestAnimationFrame(renderAllActiveLenses);
|
|
926
|
+
rafId = window.requestAnimationFrame(renderAllActiveLenses);
|
|
920
927
|
}
|
|
921
928
|
|
|
922
929
|
function attachGlobalStageListeners() {
|
package/dist/core.mjs
CHANGED
|
@@ -399,6 +399,7 @@ let blurCtx = null;
|
|
|
399
399
|
let clusterOverlayCanvas = null;
|
|
400
400
|
let clusterOverlayCtx = null;
|
|
401
401
|
let rafScheduled = false;
|
|
402
|
+
let rafId = 0;
|
|
402
403
|
let listenersAttached = false;
|
|
403
404
|
let lastFrostedRadius = -1;
|
|
404
405
|
|
|
@@ -449,8 +450,9 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
|
|
|
449
450
|
|
|
450
451
|
function ensureStageBuffers() {
|
|
451
452
|
if (typeof document === 'undefined') return false;
|
|
452
|
-
const
|
|
453
|
-
const
|
|
453
|
+
const docEl = document.documentElement;
|
|
454
|
+
const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
|
|
455
|
+
const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
|
|
454
456
|
|
|
455
457
|
if (!stageCanvas) {
|
|
456
458
|
stageCanvas = document.createElement('canvas');
|
|
@@ -497,13 +499,16 @@ function captureViewportStage(neededBlur) {
|
|
|
497
499
|
stageCtx.fillStyle = '#040814';
|
|
498
500
|
stageCtx.fillRect(0, 0, vw, vh);
|
|
499
501
|
|
|
500
|
-
// 2. Copy any background 2D canvas
|
|
502
|
+
// 2. Copy any background 2D canvas using its exact getBoundingClientRect() viewport box
|
|
501
503
|
const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
|
|
502
504
|
for (let i = 0; i < bgCanvases.length; i++) {
|
|
503
505
|
const c = bgCanvases[i];
|
|
504
506
|
if (c.width > 0 && c.height > 0) {
|
|
505
507
|
try {
|
|
506
|
-
|
|
508
|
+
const cr = c.getBoundingClientRect();
|
|
509
|
+
if (cr.width > 0 && cr.height > 0) {
|
|
510
|
+
stageCtx.drawImage(c, cr.left, cr.top, cr.width, cr.height);
|
|
511
|
+
}
|
|
507
512
|
} catch {
|
|
508
513
|
// Ignore cross-origin canvas if any
|
|
509
514
|
}
|
|
@@ -696,10 +701,9 @@ function renderIsolatedController(ctrl, rect) {
|
|
|
696
701
|
ctrl._canvas.height = h;
|
|
697
702
|
}
|
|
698
703
|
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
const
|
|
702
|
-
const dy = Math.max(0, Math.min(vh - h, Math.round(rect.top)));
|
|
704
|
+
// Use exact un-clamped element position so dragging near/past mobile viewport edges never shifts the image
|
|
705
|
+
const dx = Math.round(rect.left);
|
|
706
|
+
const dy = Math.round(rect.top);
|
|
703
707
|
|
|
704
708
|
const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
|
|
705
709
|
const srcImg = sourceCtx.getImageData(dx, dy, w, h);
|
|
@@ -914,9 +918,12 @@ function renderAllActiveLenses() {
|
|
|
914
918
|
}
|
|
915
919
|
|
|
916
920
|
export function scheduleStageRedraw() {
|
|
917
|
-
if (
|
|
921
|
+
if (typeof window === 'undefined') return;
|
|
922
|
+
if (rafScheduled && rafId) {
|
|
923
|
+
window.cancelAnimationFrame(rafId);
|
|
924
|
+
}
|
|
918
925
|
rafScheduled = true;
|
|
919
|
-
window.requestAnimationFrame(renderAllActiveLenses);
|
|
926
|
+
rafId = window.requestAnimationFrame(renderAllActiveLenses);
|
|
920
927
|
}
|
|
921
928
|
|
|
922
929
|
function attachGlobalStageListeners() {
|
package/example/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# `@shulya/liquid-glass` — React Interactive Playground (`example/`)
|
|
2
2
|
|
|
3
3
|
- **Live Demo:** [https://liquid.shulya.tech](https://liquid.shulya.tech)
|
|
4
|
+
- **NPM Package:** [https://www.npmjs.com/package/@shulya/liquid-glass](https://www.npmjs.com/package/@shulya/liquid-glass)
|
|
4
5
|
- **GitHub Repository:** [https://github.com/shulya-tech/liquid-glass](https://github.com/shulya-tech/liquid-glass)
|
|
5
6
|
|
|
6
7
|

|
package/example/src/App.jsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback
|
|
7
|
+
} from 'react';
|
|
2
8
|
import {
|
|
3
9
|
LiquidGlass,
|
|
4
10
|
OPTICAL_DEFAULTS,
|
|
@@ -6,20 +12,17 @@ import {
|
|
|
6
12
|
} from '@shulya/liquid-glass/react';
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
|
-
* Draws a crisp, high-DPI optical test stage onto the background canvas
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - Thin diagonal rainbow gradient ribbon
|
|
13
|
-
* - Compact electric blue card (right-center)
|
|
14
|
-
* - Refined sunset radial sphere (bottom-left)
|
|
15
|
-
* - Fine 8px optical stripe grating (bottom-right)
|
|
16
|
-
* - Clean centered "Shulya" typography
|
|
15
|
+
* Draws a crisp, high-DPI optical test stage onto the background canvas
|
|
16
|
+
* using its exact rendered getBoundingClientRect() dimensions so desktop
|
|
17
|
+
* and mobile viewports always match 1:1 to the sub-pixel.
|
|
17
18
|
*/
|
|
18
19
|
function drawStageBackground(canvas) {
|
|
19
20
|
if (!canvas) return;
|
|
20
|
-
const
|
|
21
|
-
const
|
|
21
|
+
const rect = canvas.getBoundingClientRect();
|
|
22
|
+
const w = Math.max(1, Math.round(rect.width || window.innerWidth));
|
|
23
|
+
const h = Math.max(1, Math.round(rect.height || window.innerHeight));
|
|
22
24
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
25
|
+
const isMobile = w < 640;
|
|
23
26
|
|
|
24
27
|
canvas.width = Math.floor(w * dpr);
|
|
25
28
|
canvas.height = Math.floor(h * dpr);
|
|
@@ -37,7 +40,7 @@ function drawStageBackground(canvas) {
|
|
|
37
40
|
// Fine architectural hairline grid (56px step, 0.5px crisp stroke)
|
|
38
41
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.035)';
|
|
39
42
|
ctx.lineWidth = 0.5;
|
|
40
|
-
const step = 56;
|
|
43
|
+
const step = isMobile ? 44 : 56;
|
|
41
44
|
ctx.beginPath();
|
|
42
45
|
for (let x = 0; x < w; x += step) {
|
|
43
46
|
ctx.moveTo(x + 0.5, 0);
|
|
@@ -50,10 +53,10 @@ function drawStageBackground(canvas) {
|
|
|
50
53
|
ctx.stroke();
|
|
51
54
|
|
|
52
55
|
// 2. Sleek Green Switch Track (Top-Left)
|
|
53
|
-
const pillX = w * 0.16;
|
|
54
|
-
const pillY = h * 0.22;
|
|
55
|
-
const pillW = 160;
|
|
56
|
-
const pillH = 26;
|
|
56
|
+
const pillX = isMobile ? w * 0.24 : w * 0.16;
|
|
57
|
+
const pillY = isMobile ? h * 0.18 : h * 0.22;
|
|
58
|
+
const pillW = isMobile ? 116 : 160;
|
|
59
|
+
const pillH = isMobile ? 20 : 26;
|
|
57
60
|
ctx.save();
|
|
58
61
|
ctx.beginPath();
|
|
59
62
|
ctx.roundRect(pillX - pillW / 2, pillY - pillH / 2, pillW, pillH, pillH / 2);
|
|
@@ -63,10 +66,10 @@ function drawStageBackground(canvas) {
|
|
|
63
66
|
|
|
64
67
|
// 3. Thin Diagonal Rainbow Gradient Ribbon
|
|
65
68
|
ctx.save();
|
|
66
|
-
ctx.translate(w * 0.5, h * 0.51);
|
|
69
|
+
ctx.translate(w * 0.5, isMobile ? h * 0.44 : h * 0.51);
|
|
67
70
|
ctx.rotate(-0.2);
|
|
68
|
-
const ribHalfLen = Math.min(w * 0.28, 310);
|
|
69
|
-
const ribThickness = 26;
|
|
71
|
+
const ribHalfLen = isMobile ? Math.min(w * 0.4, 150) : Math.min(w * 0.28, 310);
|
|
72
|
+
const ribThickness = isMobile ? 18 : 26;
|
|
70
73
|
const ribGrad = ctx.createLinearGradient(-ribHalfLen, 0, ribHalfLen, 0);
|
|
71
74
|
ribGrad.addColorStop(0.0, '#ff2d55');
|
|
72
75
|
ribGrad.addColorStop(0.25, '#ff9500');
|
|
@@ -87,25 +90,25 @@ function drawStageBackground(canvas) {
|
|
|
87
90
|
|
|
88
91
|
// 4. Refined Electric Blue Card (Right Center)
|
|
89
92
|
ctx.save();
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
93
|
+
const cardW = isMobile ? 108 : 152;
|
|
94
|
+
const cardH = isMobile ? 64 : 88;
|
|
95
|
+
const cardX = isMobile ? w * 0.74 - cardW * 0.5 : w * 0.67;
|
|
96
|
+
const cardY = isMobile ? h * 0.17 : h * 0.24;
|
|
94
97
|
const blueGrad = ctx.createLinearGradient(cardX, cardY, cardX + cardW, cardY + cardH);
|
|
95
98
|
blueGrad.addColorStop(0, '#007aff');
|
|
96
99
|
blueGrad.addColorStop(1, '#5856d6');
|
|
97
100
|
ctx.fillStyle = blueGrad;
|
|
98
101
|
ctx.beginPath();
|
|
99
|
-
ctx.roundRect(cardX, cardY, cardW, cardH, 22);
|
|
102
|
+
ctx.roundRect(cardX, cardY, cardW, cardH, isMobile ? 16 : 22);
|
|
100
103
|
ctx.fill();
|
|
101
104
|
ctx.restore();
|
|
102
105
|
|
|
103
106
|
// 5. Compact Sunset Orange Sphere (Bottom Left)
|
|
104
107
|
ctx.save();
|
|
105
|
-
const orbX = w * 0.26;
|
|
106
|
-
const orbY = h * 0.67;
|
|
107
|
-
const orbR = 54;
|
|
108
|
-
const orbGrad = ctx.createRadialGradient(orbX -
|
|
108
|
+
const orbX = isMobile ? w * 0.25 : w * 0.26;
|
|
109
|
+
const orbY = isMobile ? h * 0.60 : h * 0.67;
|
|
110
|
+
const orbR = isMobile ? 38 : 54;
|
|
111
|
+
const orbGrad = ctx.createRadialGradient(orbX - 10, orbY - 10, 4, orbX, orbY, orbR);
|
|
109
112
|
orbGrad.addColorStop(0, '#ffcc00');
|
|
110
113
|
orbGrad.addColorStop(0.6, '#ff3b30');
|
|
111
114
|
orbGrad.addColorStop(1, '#c91f66');
|
|
@@ -115,15 +118,15 @@ function drawStageBackground(canvas) {
|
|
|
115
118
|
ctx.fill();
|
|
116
119
|
ctx.restore();
|
|
117
120
|
|
|
118
|
-
// 6. Fine Optical Stripe Grating (Bottom Right
|
|
121
|
+
// 6. Fine Optical Stripe Grating (Bottom Right)
|
|
119
122
|
ctx.save();
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const stripeWidth = 10;
|
|
123
|
+
const stW = isMobile ? 112 : 160;
|
|
124
|
+
const stH = isMobile ? 68 : 92;
|
|
125
|
+
const stX = isMobile ? w * 0.72 - stW * 0.5 : w * 0.63;
|
|
126
|
+
const stY = isMobile ? h * 0.56 : h * 0.62;
|
|
127
|
+
const stripeWidth = isMobile ? 8 : 10;
|
|
125
128
|
ctx.beginPath();
|
|
126
|
-
ctx.roundRect(stX, stY, stW, stH, 18);
|
|
129
|
+
ctx.roundRect(stX, stY, stW, stH, isMobile ? 14 : 18);
|
|
127
130
|
ctx.clip();
|
|
128
131
|
for (let i = 0; i < stW / stripeWidth; i++) {
|
|
129
132
|
ctx.fillStyle = i % 2 === 0 ? '#1c1c1e' : '#ffffff';
|
|
@@ -133,17 +136,33 @@ function drawStageBackground(canvas) {
|
|
|
133
136
|
|
|
134
137
|
// 7. Sleek Center Typography ("Shulya")
|
|
135
138
|
ctx.save();
|
|
136
|
-
const fontSize = Math.min(w * 0.082, 92);
|
|
139
|
+
const fontSize = isMobile ? Math.min(w * 0.13, 52) : Math.min(w * 0.082, 92);
|
|
137
140
|
ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif`;
|
|
138
141
|
ctx.textAlign = 'center';
|
|
139
142
|
ctx.textBaseline = 'middle';
|
|
140
143
|
ctx.fillStyle = '#16181d';
|
|
141
|
-
ctx.fillText('Shulya', w * 0.5, h * 0.43);
|
|
144
|
+
ctx.fillText('Shulya', w * 0.5, isMobile ? h * 0.38 : h * 0.43);
|
|
142
145
|
ctx.restore();
|
|
143
146
|
|
|
144
147
|
ctx.restore();
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
function createInitialShapes(w, h) {
|
|
151
|
+
const isMobile = w < 640;
|
|
152
|
+
if (isMobile) {
|
|
153
|
+
return [
|
|
154
|
+
{ id: 'slider', x: w * 0.26, y: h * 0.18, w: 104, h: 58, r: 29 },
|
|
155
|
+
{ id: 'rect', x: w * 0.74, y: h * 0.21, w: 132, h: 82, r: 18 },
|
|
156
|
+
{ id: 'circle', x: w * 0.26, y: h * 0.60, w: 88, h: 88, r: 44 }
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
return [
|
|
160
|
+
{ id: 'slider', x: w * 0.16 + 42, y: h * 0.22, w: 136, h: 74, r: 37 },
|
|
161
|
+
{ id: 'rect', x: w * 0.71, y: h * 0.21, w: 196, h: 116, r: 22 },
|
|
162
|
+
{ id: 'circle', x: w * 0.22, y: h * 0.65, w: 112, h: 112, r: 56 }
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
147
166
|
export default function App() {
|
|
148
167
|
const stageRef = useRef(null);
|
|
149
168
|
|
|
@@ -159,29 +178,54 @@ export default function App() {
|
|
|
159
178
|
const [shapes, setShapes] = useState(() => {
|
|
160
179
|
const w = typeof window !== 'undefined' ? window.innerWidth : 1280;
|
|
161
180
|
const h = typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
162
|
-
return
|
|
163
|
-
{ id: 'slider', x: w * 0.16 + 42, y: h * 0.22, w: 136, h: 74, r: 37 },
|
|
164
|
-
{ id: 'rect', x: w * 0.71, y: h * 0.21, w: 196, h: 116, r: 22 },
|
|
165
|
-
{ id: 'circle', x: w * 0.22, y: h * 0.65, w: 112, h: 112, r: 56 }
|
|
166
|
-
];
|
|
181
|
+
return createInitialShapes(w, h);
|
|
167
182
|
});
|
|
168
183
|
|
|
169
184
|
const dragState = useRef(null);
|
|
185
|
+
const lastModeRef = useRef(
|
|
186
|
+
typeof window !== 'undefined' ? window.innerWidth < 640 : false
|
|
187
|
+
);
|
|
170
188
|
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
// Synchronously redraw stage background before browser paint and on resize
|
|
190
|
+
useLayoutEffect(() => {
|
|
191
|
+
const canvas = stageRef.current;
|
|
192
|
+
if (!canvas) return;
|
|
175
193
|
|
|
176
|
-
const
|
|
177
|
-
|
|
194
|
+
const syncStage = () => {
|
|
195
|
+
const rect = canvas.getBoundingClientRect();
|
|
196
|
+
const w = Math.max(1, Math.round(rect.width || window.innerWidth));
|
|
197
|
+
const h = Math.max(1, Math.round(rect.height || window.innerHeight));
|
|
198
|
+
const isMobile = w < 640;
|
|
199
|
+
|
|
200
|
+
if (isMobile !== lastModeRef.current) {
|
|
201
|
+
lastModeRef.current = isMobile;
|
|
202
|
+
setShapes(createInitialShapes(w, h));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
drawStageBackground(canvas);
|
|
178
206
|
scheduleStageRedraw();
|
|
179
207
|
};
|
|
180
208
|
|
|
181
|
-
|
|
182
|
-
|
|
209
|
+
syncStage();
|
|
210
|
+
|
|
211
|
+
let ro = null;
|
|
212
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
213
|
+
ro = new ResizeObserver(syncStage);
|
|
214
|
+
ro.observe(canvas);
|
|
215
|
+
}
|
|
216
|
+
window.addEventListener('resize', syncStage);
|
|
217
|
+
|
|
218
|
+
return () => {
|
|
219
|
+
if (ro) ro.disconnect();
|
|
220
|
+
window.removeEventListener('resize', syncStage);
|
|
221
|
+
};
|
|
183
222
|
}, []);
|
|
184
223
|
|
|
224
|
+
// Trigger optical redraw synchronously after React commits new shape positions or parameters
|
|
225
|
+
useLayoutEffect(() => {
|
|
226
|
+
scheduleStageRedraw();
|
|
227
|
+
}, [shapes, rim, ior, height, frosted, fusion]);
|
|
228
|
+
|
|
185
229
|
// FPS monitor
|
|
186
230
|
useEffect(() => {
|
|
187
231
|
let frameCount = 0;
|
|
@@ -232,7 +276,6 @@ export default function App() {
|
|
|
232
276
|
setShapes((prev) =>
|
|
233
277
|
prev.map((s) => (s.id === id ? { ...s, x: nextX, y: nextY } : s))
|
|
234
278
|
);
|
|
235
|
-
scheduleStageRedraw();
|
|
236
279
|
};
|
|
237
280
|
|
|
238
281
|
const handlePointerUp = () => {
|
|
@@ -241,9 +284,11 @@ export default function App() {
|
|
|
241
284
|
|
|
242
285
|
window.addEventListener('pointermove', handlePointerMove);
|
|
243
286
|
window.addEventListener('pointerup', handlePointerUp);
|
|
287
|
+
window.addEventListener('pointercancel', handlePointerUp);
|
|
244
288
|
return () => {
|
|
245
289
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
246
290
|
window.removeEventListener('pointerup', handlePointerUp);
|
|
291
|
+
window.removeEventListener('pointercancel', handlePointerUp);
|
|
247
292
|
};
|
|
248
293
|
}, []);
|
|
249
294
|
|
package/example/src/styles.css
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
html,
|
|
10
10
|
body,
|
|
11
11
|
#root {
|
|
12
|
+
position: fixed;
|
|
13
|
+
inset: 0;
|
|
12
14
|
width: 100%;
|
|
13
15
|
height: 100%;
|
|
14
16
|
overflow: hidden;
|
|
@@ -21,9 +23,10 @@ body,
|
|
|
21
23
|
|
|
22
24
|
.stage-canvas {
|
|
23
25
|
position: fixed;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
top: 0;
|
|
27
|
+
left: 0;
|
|
28
|
+
width: 100%;
|
|
29
|
+
height: 100%;
|
|
27
30
|
display: block;
|
|
28
31
|
z-index: 1;
|
|
29
32
|
pointer-events: none;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shulya/liquid-glass",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "High-performance, zero-WebGL Liquid Glass optical UI engine with pre-computed empirical lookup tables for React, Vue, Svelte, Angular, SolidJS, and Vanilla JS.",
|
|
5
5
|
"author": "Shulya (https://shulya.tech)",
|
|
6
6
|
"homepage": "https://liquid.shulya.tech",
|
|
@@ -114,4 +114,3 @@
|
|
|
114
114
|
"sideEffects": false,
|
|
115
115
|
"license": "MIT"
|
|
116
116
|
}
|
|
117
|
-
|
package/src/core.js
CHANGED
|
@@ -399,6 +399,7 @@ let blurCtx = null;
|
|
|
399
399
|
let clusterOverlayCanvas = null;
|
|
400
400
|
let clusterOverlayCtx = null;
|
|
401
401
|
let rafScheduled = false;
|
|
402
|
+
let rafId = 0;
|
|
402
403
|
let listenersAttached = false;
|
|
403
404
|
let lastFrostedRadius = -1;
|
|
404
405
|
|
|
@@ -449,8 +450,9 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
|
|
|
449
450
|
|
|
450
451
|
function ensureStageBuffers() {
|
|
451
452
|
if (typeof document === 'undefined') return false;
|
|
452
|
-
const
|
|
453
|
-
const
|
|
453
|
+
const docEl = document.documentElement;
|
|
454
|
+
const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
|
|
455
|
+
const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
|
|
454
456
|
|
|
455
457
|
if (!stageCanvas) {
|
|
456
458
|
stageCanvas = document.createElement('canvas');
|
|
@@ -497,13 +499,16 @@ function captureViewportStage(neededBlur) {
|
|
|
497
499
|
stageCtx.fillStyle = '#040814';
|
|
498
500
|
stageCtx.fillRect(0, 0, vw, vh);
|
|
499
501
|
|
|
500
|
-
// 2. Copy any background 2D canvas
|
|
502
|
+
// 2. Copy any background 2D canvas using its exact getBoundingClientRect() viewport box
|
|
501
503
|
const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
|
|
502
504
|
for (let i = 0; i < bgCanvases.length; i++) {
|
|
503
505
|
const c = bgCanvases[i];
|
|
504
506
|
if (c.width > 0 && c.height > 0) {
|
|
505
507
|
try {
|
|
506
|
-
|
|
508
|
+
const cr = c.getBoundingClientRect();
|
|
509
|
+
if (cr.width > 0 && cr.height > 0) {
|
|
510
|
+
stageCtx.drawImage(c, cr.left, cr.top, cr.width, cr.height);
|
|
511
|
+
}
|
|
507
512
|
} catch {
|
|
508
513
|
// Ignore cross-origin canvas if any
|
|
509
514
|
}
|
|
@@ -696,10 +701,9 @@ function renderIsolatedController(ctrl, rect) {
|
|
|
696
701
|
ctrl._canvas.height = h;
|
|
697
702
|
}
|
|
698
703
|
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
const
|
|
702
|
-
const dy = Math.max(0, Math.min(vh - h, Math.round(rect.top)));
|
|
704
|
+
// Use exact un-clamped element position so dragging near/past mobile viewport edges never shifts the image
|
|
705
|
+
const dx = Math.round(rect.left);
|
|
706
|
+
const dy = Math.round(rect.top);
|
|
703
707
|
|
|
704
708
|
const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
|
|
705
709
|
const srcImg = sourceCtx.getImageData(dx, dy, w, h);
|
|
@@ -914,9 +918,12 @@ function renderAllActiveLenses() {
|
|
|
914
918
|
}
|
|
915
919
|
|
|
916
920
|
export function scheduleStageRedraw() {
|
|
917
|
-
if (
|
|
921
|
+
if (typeof window === 'undefined') return;
|
|
922
|
+
if (rafScheduled && rafId) {
|
|
923
|
+
window.cancelAnimationFrame(rafId);
|
|
924
|
+
}
|
|
918
925
|
rafScheduled = true;
|
|
919
|
-
window.requestAnimationFrame(renderAllActiveLenses);
|
|
926
|
+
rafId = window.requestAnimationFrame(renderAllActiveLenses);
|
|
920
927
|
}
|
|
921
928
|
|
|
922
929
|
function attachGlobalStageListeners() {
|