@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 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
  ![Liquid Glass Interactive Playground](./screen.png)
@@ -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 vw = Math.max(1, window.innerWidth);
453
- const vh = Math.max(1, window.innerHeight);
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
- stageCtx.drawImage(c, 0, 0, vw, vh);
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
- const vw = stageCanvas.width;
700
- const vh = stageCanvas.height;
701
- const dx = Math.max(0, Math.min(vw - w, Math.round(rect.left)));
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 (rafScheduled || typeof window === 'undefined') return;
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 vw = Math.max(1, window.innerWidth);
453
- const vh = Math.max(1, window.innerHeight);
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
- stageCtx.drawImage(c, 0, 0, vw, vh);
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
- const vw = stageCanvas.width;
700
- const vh = stageCanvas.height;
701
- const dx = Math.max(0, Math.min(vw - w, Math.round(rect.left)));
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 (rafScheduled || typeof window === 'undefined') return;
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
  ![Liquid Glass React Playground](../screen.png)
@@ -1,4 +1,10 @@
1
- import React, { useEffect, useRef, useState, useCallback } from '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
- * - Light architectural backdrop (#ebedf2) with fine 56px hairline grid
11
- * - Sleek green switch pill track (top-left)
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 w = window.innerWidth;
21
- const h = window.innerHeight;
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 cardX = w * 0.67;
91
- const cardY = h * 0.24;
92
- const cardW = 152;
93
- const cardH = 88;
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 - 14, orbY - 14, 6, orbX, orbY, orbR);
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 — 10px stripes for refraction testing)
121
+ // 6. Fine Optical Stripe Grating (Bottom Right)
119
122
  ctx.save();
120
- const stX = w * 0.63;
121
- const stY = h * 0.62;
122
- const stW = 160;
123
- const stH = 92;
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
- // Draw stage canvas and handle window resize
172
- useEffect(() => {
173
- drawStageBackground(stageRef.current);
174
- scheduleStageRedraw();
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 handleResize = () => {
177
- drawStageBackground(stageRef.current);
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
- window.addEventListener('resize', handleResize);
182
- return () => window.removeEventListener('resize', handleResize);
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
 
@@ -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
- inset: 0;
25
- width: 100vw;
26
- height: 100vh;
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.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 vw = Math.max(1, window.innerWidth);
453
- const vh = Math.max(1, window.innerHeight);
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
- stageCtx.drawImage(c, 0, 0, vw, vh);
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
- const vw = stageCanvas.width;
700
- const vh = stageCanvas.height;
701
- const dx = Math.max(0, Math.min(vw - w, Math.round(rect.left)));
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 (rafScheduled || typeof window === 'undefined') return;
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() {