@shulya/liquid-glass 1.0.3 → 1.0.5
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/dist/core.cjs +187 -75
- package/dist/core.mjs +187 -75
- package/dist/react.cjs +7 -3
- package/dist/react.mjs +7 -3
- package/package.json +1 -1
- package/types/index.d.ts +8 -0
package/dist/core.cjs
CHANGED
|
@@ -13,14 +13,16 @@ const OPTICAL_DEFAULTS = exports.OPTICAL_DEFAULTS = Object.freeze({
|
|
|
13
13
|
ior: 1.46,
|
|
14
14
|
height: 18,
|
|
15
15
|
frosted: 0,
|
|
16
|
-
fusion: true
|
|
16
|
+
fusion: true,
|
|
17
|
+
dpr: 0
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
const OPTICAL_LIMITS = exports.OPTICAL_LIMITS = Object.freeze({
|
|
20
21
|
rim: Object.freeze({ min: 4, max: 48 }),
|
|
21
22
|
ior: Object.freeze({ min: 1.05, max: 2.20 }),
|
|
22
23
|
height: Object.freeze({ min: 2, max: 50 }),
|
|
23
|
-
frosted: Object.freeze({ min: 0, max: 32 })
|
|
24
|
+
frosted: Object.freeze({ min: 0, max: 32 }),
|
|
25
|
+
dpr: Object.freeze({ min: 0.5, max: 3.0 })
|
|
24
26
|
});
|
|
25
27
|
|
|
26
28
|
function clampNumber(val, fallback, min, max) {
|
|
@@ -57,7 +59,11 @@ exports.normalizeOptions = function normalizeOptions(options = {}) {
|
|
|
57
59
|
OPTICAL_LIMITS.frosted.min,
|
|
58
60
|
OPTICAL_LIMITS.frosted.max
|
|
59
61
|
),
|
|
60
|
-
fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion
|
|
62
|
+
fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion,
|
|
63
|
+
dpr:
|
|
64
|
+
typeof options.dpr === 'number' && options.dpr > 0
|
|
65
|
+
? clampNumber(options.dpr, 1, OPTICAL_LIMITS.dpr.min, OPTICAL_LIMITS.dpr.max)
|
|
66
|
+
: 0
|
|
61
67
|
};
|
|
62
68
|
}
|
|
63
69
|
|
|
@@ -221,7 +227,7 @@ exports.buildOpticalMapURI = function buildOpticalMapURI(width, height, borderRa
|
|
|
221
227
|
*/
|
|
222
228
|
exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dpr = 1) {
|
|
223
229
|
const options = normalizeOptions(rawOptions);
|
|
224
|
-
const fusionSpan = options.fusion ? (rawOptions.fusionDistance ??
|
|
230
|
+
const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 24) * dpr : 0;
|
|
225
231
|
const rimPx = Math.max(1, options.rim * dpr);
|
|
226
232
|
const ior = options.ior;
|
|
227
233
|
const heightPx = options.height * dpr;
|
|
@@ -259,19 +265,26 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
|
|
|
259
265
|
const dstData = dstImg.data;
|
|
260
266
|
|
|
261
267
|
const distGrid = new Float32Array(boxW * boxH);
|
|
262
|
-
const prep = elements.map((b) =>
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
268
|
+
const prep = elements.map((b) => {
|
|
269
|
+
const halfW = b.w * 0.5 * dpr;
|
|
270
|
+
const halfH = b.h * 0.5 * dpr;
|
|
271
|
+
const rad = (b.borderRadius ?? 0) * dpr;
|
|
272
|
+
const r = Math.min(rad, halfW, halfH);
|
|
273
|
+
const isCircle = b.shape === 'circle' || (Math.abs(halfW - halfH) < 1.0 && Math.abs(r - halfW) < 1.0);
|
|
274
|
+
return {
|
|
275
|
+
cx: (b.x + b.w * 0.5) * dpr,
|
|
276
|
+
cy: (b.y + b.h * 0.5) * dpr,
|
|
277
|
+
halfW,
|
|
278
|
+
halfH,
|
|
279
|
+
r,
|
|
280
|
+
isCircle,
|
|
281
|
+
fusion: b.fusion !== undefined ? Boolean(b.fusion) : options.fusion,
|
|
282
|
+
tintR: b.tint ? b.tint[0] : 15,
|
|
283
|
+
tintG: b.tint ? b.tint[1] : 23,
|
|
284
|
+
tintB: b.tint ? b.tint[2] : 42,
|
|
285
|
+
tintA: b.tint ? b.tint[3] : 0.35
|
|
286
|
+
};
|
|
287
|
+
});
|
|
275
288
|
|
|
276
289
|
const primary = prep[0];
|
|
277
290
|
const tR = primary.tintR;
|
|
@@ -279,8 +292,10 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
|
|
|
279
292
|
const tB = primary.tintB;
|
|
280
293
|
const tA = primary.tintA;
|
|
281
294
|
const invTA = 1.0 - tA;
|
|
295
|
+
const span = fusionSpan;
|
|
296
|
+
const invSpan = span > 0.001 ? 1.0 / span : 0;
|
|
282
297
|
|
|
283
|
-
// Pass 1: Boundary evaluation across cluster bounding box
|
|
298
|
+
// Pass 1: Boundary evaluation across cluster bounding box (Inlined fast LUT math)
|
|
284
299
|
for (let y = 0; y < boxH; y++) {
|
|
285
300
|
const py = cMinY + y;
|
|
286
301
|
const row = y * boxW;
|
|
@@ -289,22 +304,36 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
|
|
|
289
304
|
let dCluster = Infinity;
|
|
290
305
|
for (let i = 0; i < prep.length; i++) {
|
|
291
306
|
const b = prep[i];
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
307
|
+
let dShape;
|
|
308
|
+
if (b.isCircle) {
|
|
309
|
+
dShape = Math.hypot(px - b.cx, py - b.cy) - b.halfW;
|
|
310
|
+
} else {
|
|
311
|
+
const qx = Math.abs(px - b.cx) - b.halfW + b.r;
|
|
312
|
+
const qy = Math.abs(py - b.cy) - b.halfH + b.r;
|
|
313
|
+
dShape = Math.min(Math.max(qx, qy), 0.0) + Math.hypot(Math.max(qx, 0.0), Math.max(qy, 0.0)) - b.r;
|
|
314
|
+
}
|
|
315
|
+
|
|
295
316
|
if (i === 0) {
|
|
296
317
|
dCluster = dShape;
|
|
297
|
-
} else if (b.fusion &&
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
318
|
+
} else if (b.fusion && span > 0.001) {
|
|
319
|
+
const diff = dCluster > dShape ? dCluster - dShape : dShape - dCluster;
|
|
320
|
+
if (diff < span) {
|
|
321
|
+
const pos = diff * invSpan * 255.0;
|
|
322
|
+
const pIdx = Math.min(254, pos | 0);
|
|
323
|
+
const wWeight = _T3[pIdx] + (_T3[pIdx + 1] - _T3[pIdx]) * (pos - pIdx);
|
|
324
|
+
dCluster = (dCluster < dShape ? dCluster : dShape) - wWeight * span;
|
|
325
|
+
} else if (dShape < dCluster) {
|
|
326
|
+
dCluster = dShape;
|
|
327
|
+
}
|
|
328
|
+
} else if (dShape < dCluster) {
|
|
329
|
+
dCluster = dShape;
|
|
301
330
|
}
|
|
302
331
|
}
|
|
303
332
|
distGrid[row + x] = dCluster;
|
|
304
333
|
}
|
|
305
334
|
}
|
|
306
335
|
|
|
307
|
-
// Pass 2:
|
|
336
|
+
// Pass 2: Fast vectorized lookup from distGrid + tabular chromatic shift (_T4)
|
|
308
337
|
for (let y = 1; y < boxH - 1; y++) {
|
|
309
338
|
const row = y * boxW;
|
|
310
339
|
for (let x = 1; x < boxW - 1; x++) {
|
|
@@ -325,8 +354,11 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
|
|
|
325
354
|
const innerDist = -d;
|
|
326
355
|
if (innerDist < rimPx) {
|
|
327
356
|
const u = innerDist / rimPx;
|
|
328
|
-
const
|
|
329
|
-
|
|
357
|
+
const posU = u * 255.0;
|
|
358
|
+
const uIdx = Math.min(254, posU | 0);
|
|
359
|
+
const uFrac = posU - uIdx;
|
|
360
|
+
const gain = _T0[uIdx] + (_T0[uIdx + 1] - _T0[uIdx]) * uFrac;
|
|
361
|
+
disp = _T4[uIdx] + (_T4[uIdx + 1] - _T4[uIdx]) * uFrac;
|
|
330
362
|
|
|
331
363
|
const dx = distGrid[row + x + 1] - distGrid[row + x - 1];
|
|
332
364
|
const dy = distGrid[(y + 1) * boxW + x] - distGrid[(y - 1) * boxW + x];
|
|
@@ -339,7 +371,8 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
|
|
|
339
371
|
offY = Math.round(-gy * shift);
|
|
340
372
|
|
|
341
373
|
if (gy < -0.2) {
|
|
342
|
-
|
|
374
|
+
const sVal = _T2[uIdx] + (_T2[uIdx + 1] - _T2[uIdx]) * uFrac;
|
|
375
|
+
sheen = Math.round(sVal * (-gy) * 0.78);
|
|
343
376
|
}
|
|
344
377
|
} else {
|
|
345
378
|
// Fast interior path: zero displacement inside flat region (-d >= rimPx)
|
|
@@ -354,7 +387,9 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
|
|
|
354
387
|
|
|
355
388
|
const absD = Math.abs(d);
|
|
356
389
|
if (absD < 3.0) {
|
|
357
|
-
|
|
390
|
+
const posG = Math.min(254, absD * 0.33 * 255.0);
|
|
391
|
+
const gIdx = posG | 0;
|
|
392
|
+
glint = Math.round((_T1[gIdx] + (_T1[gIdx + 1] - _T1[gIdx]) * (posG - gIdx)) * 0.65);
|
|
358
393
|
}
|
|
359
394
|
|
|
360
395
|
const sxG = Math.max(0, Math.min(boxW - 1, x + offX));
|
|
@@ -448,11 +483,27 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
|
|
|
448
483
|
return fallback;
|
|
449
484
|
}
|
|
450
485
|
|
|
486
|
+
function getDevicePixelRatio(customDpr = 0, elementWidth = 0) {
|
|
487
|
+
if (typeof customDpr === 'number' && customDpr > 0) {
|
|
488
|
+
return Math.max(0.5, Math.min(3.0, customDpr));
|
|
489
|
+
}
|
|
490
|
+
if (typeof window === 'undefined') return 1;
|
|
491
|
+
const sysDpr = window.devicePixelRatio || 1;
|
|
492
|
+
// Automatically use 1.0 for wide elements (headers, banners >= 700px) to guarantee 120 FPS
|
|
493
|
+
if (elementWidth >= 700) {
|
|
494
|
+
return 1;
|
|
495
|
+
}
|
|
496
|
+
return Math.min(sysDpr, 1.5);
|
|
497
|
+
}
|
|
498
|
+
|
|
451
499
|
function ensureStageBuffers() {
|
|
452
500
|
if (typeof document === 'undefined') return false;
|
|
501
|
+
const dpr = getDevicePixelRatio();
|
|
453
502
|
const docEl = document.documentElement;
|
|
454
503
|
const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
|
|
455
504
|
const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
|
|
505
|
+
const bufW = Math.round(vw * dpr);
|
|
506
|
+
const bufH = Math.round(vh * dpr);
|
|
456
507
|
|
|
457
508
|
if (!stageCanvas) {
|
|
458
509
|
stageCanvas = document.createElement('canvas');
|
|
@@ -461,11 +512,11 @@ function ensureStageBuffers() {
|
|
|
461
512
|
blurCtx = blurCanvas.getContext('2d', { willReadFrequently: true });
|
|
462
513
|
}
|
|
463
514
|
|
|
464
|
-
if (stageCanvas.width !==
|
|
465
|
-
stageCanvas.width =
|
|
466
|
-
stageCanvas.height =
|
|
467
|
-
blurCanvas.width =
|
|
468
|
-
blurCanvas.height =
|
|
515
|
+
if (stageCanvas.width !== bufW || stageCanvas.height !== bufH) {
|
|
516
|
+
stageCanvas.width = bufW;
|
|
517
|
+
stageCanvas.height = bufH;
|
|
518
|
+
blurCanvas.width = bufW;
|
|
519
|
+
blurCanvas.height = bufH;
|
|
469
520
|
}
|
|
470
521
|
|
|
471
522
|
if (!clusterOverlayCanvas) {
|
|
@@ -492,14 +543,20 @@ function ensureStageBuffers() {
|
|
|
492
543
|
*/
|
|
493
544
|
function captureViewportStage(neededBlur) {
|
|
494
545
|
if (!ensureStageBuffers()) return;
|
|
495
|
-
const
|
|
496
|
-
const
|
|
546
|
+
const dpr = getDevicePixelRatio();
|
|
547
|
+
const bw = stageCanvas.width;
|
|
548
|
+
const bh = stageCanvas.height;
|
|
549
|
+
const vw = Math.round(bw / dpr);
|
|
550
|
+
const vh = Math.round(bh / dpr);
|
|
497
551
|
|
|
498
|
-
|
|
499
|
-
stageCtx.fillStyle = '#040814';
|
|
500
|
-
stageCtx.fillRect(0, 0, vw, vh);
|
|
552
|
+
stageCtx.save();
|
|
501
553
|
|
|
502
|
-
//
|
|
554
|
+
// 1. Base page background (detect light/dark body background)
|
|
555
|
+
const bodyBg = typeof document !== 'undefined' ? window.getComputedStyle(document.body).backgroundColor : '';
|
|
556
|
+
stageCtx.fillStyle = bodyBg && bodyBg !== 'transparent' && bodyBg !== 'rgba(0, 0, 0, 0)' ? bodyBg : '#040814';
|
|
557
|
+
stageCtx.fillRect(0, 0, bw, bh);
|
|
558
|
+
|
|
559
|
+
// 2. Copy any background 2D canvas using physical buffer pixels
|
|
503
560
|
const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
|
|
504
561
|
for (let i = 0; i < bgCanvases.length; i++) {
|
|
505
562
|
const c = bgCanvases[i];
|
|
@@ -507,7 +564,13 @@ function captureViewportStage(neededBlur) {
|
|
|
507
564
|
try {
|
|
508
565
|
const cr = c.getBoundingClientRect();
|
|
509
566
|
if (cr.width > 0 && cr.height > 0) {
|
|
510
|
-
stageCtx.drawImage(
|
|
567
|
+
stageCtx.drawImage(
|
|
568
|
+
c,
|
|
569
|
+
Math.round(cr.left * dpr),
|
|
570
|
+
Math.round(cr.top * dpr),
|
|
571
|
+
Math.round(cr.width * dpr),
|
|
572
|
+
Math.round(cr.height * dpr)
|
|
573
|
+
);
|
|
511
574
|
}
|
|
512
575
|
} catch {
|
|
513
576
|
// Ignore cross-origin canvas if any
|
|
@@ -515,6 +578,9 @@ function captureViewportStage(neededBlur) {
|
|
|
515
578
|
}
|
|
516
579
|
}
|
|
517
580
|
|
|
581
|
+
// Scale context for DOM elements rendered in CSS pixels
|
|
582
|
+
stageCtx.scale(dpr, dpr);
|
|
583
|
+
|
|
518
584
|
// 3. Draw visible block surfaces (cards, buttons, pills, inputs) inside <main> and <footer>
|
|
519
585
|
const surfaceEls = document.querySelectorAll(
|
|
520
586
|
'main div.rounded-2xl, main div.rounded-xl, main a.rounded-2xl, main a.rounded-xl, main button, main a.inline-flex, main input, main textarea, main span.rounded-lg, main span.rounded-full'
|
|
@@ -668,13 +734,53 @@ function captureViewportStage(neededBlur) {
|
|
|
668
734
|
stageCtx.restore();
|
|
669
735
|
}
|
|
670
736
|
|
|
671
|
-
|
|
737
|
+
stageCtx.restore();
|
|
738
|
+
|
|
739
|
+
let supportsCanvasFilterBlur = null;
|
|
740
|
+
function checkCanvasFilterBlur() {
|
|
741
|
+
if (supportsCanvasFilterBlur !== null) return supportsCanvasFilterBlur;
|
|
742
|
+
if (typeof document === 'undefined') return false;
|
|
743
|
+
try {
|
|
744
|
+
const testC = document.createElement('canvas');
|
|
745
|
+
testC.width = 4;
|
|
746
|
+
testC.height = 4;
|
|
747
|
+
const testCtx = testC.getContext('2d');
|
|
748
|
+
if (!testCtx || typeof testCtx.filter !== 'string') {
|
|
749
|
+
return (supportsCanvasFilterBlur = false);
|
|
750
|
+
}
|
|
751
|
+
testCtx.fillStyle = '#000';
|
|
752
|
+
testCtx.fillRect(0, 0, 4, 4);
|
|
753
|
+
testCtx.filter = 'blur(2px)';
|
|
754
|
+
testCtx.fillStyle = '#fff';
|
|
755
|
+
testCtx.fillRect(0, 0, 2, 2);
|
|
756
|
+
testCtx.filter = 'none';
|
|
757
|
+
const imgData = testCtx.getImageData(0, 0, 4, 4).data;
|
|
758
|
+
supportsCanvasFilterBlur = imgData[15 * 4] > 0 || imgData[14 * 4] > 0 || imgData[11 * 4] > 0;
|
|
759
|
+
return supportsCanvasFilterBlur;
|
|
760
|
+
} catch {
|
|
761
|
+
return (supportsCanvasFilterBlur = false);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// 5. Blur buffer for frosted glass (Robust across all mobile WebKit & Blink engines)
|
|
672
766
|
if (neededBlur > 0) {
|
|
673
767
|
lastFrostedRadius = neededBlur;
|
|
674
|
-
blurCtx.clearRect(0, 0,
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
768
|
+
blurCtx.clearRect(0, 0, bw, bh);
|
|
769
|
+
|
|
770
|
+
if (checkCanvasFilterBlur()) {
|
|
771
|
+
blurCtx.filter = `blur(${Math.round(neededBlur * dpr)}px)`;
|
|
772
|
+
blurCtx.drawImage(stageCanvas, 0, 0);
|
|
773
|
+
blurCtx.filter = 'none';
|
|
774
|
+
} else {
|
|
775
|
+
// Hardware-accelerated multi-step pyramid downscale/upscale blur for iOS Safari and mobile WebKit
|
|
776
|
+
const factor = Math.max(0.04, 1.0 / (1.0 + neededBlur * 0.45));
|
|
777
|
+
const pw = Math.max(4, Math.round(bw * factor));
|
|
778
|
+
const ph = Math.max(4, Math.round(bh * factor));
|
|
779
|
+
blurCtx.imageSmoothingEnabled = true;
|
|
780
|
+
blurCtx.imageSmoothingQuality = 'high';
|
|
781
|
+
blurCtx.drawImage(stageCanvas, 0, 0, pw, ph);
|
|
782
|
+
blurCtx.drawImage(blurCanvas, 0, 0, pw, ph, 0, 0, bw, bh);
|
|
783
|
+
}
|
|
678
784
|
}
|
|
679
785
|
}
|
|
680
786
|
|
|
@@ -682,17 +788,19 @@ function captureViewportStage(neededBlur) {
|
|
|
682
788
|
* Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
|
|
683
789
|
*/
|
|
684
790
|
function renderIsolatedController(ctrl, rect) {
|
|
685
|
-
const
|
|
686
|
-
const
|
|
791
|
+
const dpr = getDevicePixelRatio(ctrl.options.dpr, rect.width);
|
|
792
|
+
const w = Math.max(16, Math.round(rect.width * dpr));
|
|
793
|
+
const h = Math.max(16, Math.round(rect.height * dpr));
|
|
687
794
|
const rad = ctrl._getRadius();
|
|
795
|
+
const radPx = Math.min(rad * dpr, w * 0.5, h * 0.5);
|
|
688
796
|
|
|
689
797
|
if (
|
|
690
798
|
!ctrl._lut ||
|
|
691
799
|
ctrl._lut.w !== w ||
|
|
692
800
|
ctrl._lut.h !== h ||
|
|
693
|
-
Math.abs(ctrl._lut.rad -
|
|
801
|
+
Math.abs(ctrl._lut.rad - radPx) > 1.0
|
|
694
802
|
) {
|
|
695
|
-
ctrl._lut = buildElementLUT(w, h, rad, ctrl.options,
|
|
803
|
+
ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, dpr);
|
|
696
804
|
}
|
|
697
805
|
|
|
698
806
|
const lut = ctrl._lut;
|
|
@@ -701,9 +809,9 @@ function renderIsolatedController(ctrl, rect) {
|
|
|
701
809
|
ctrl._canvas.height = h;
|
|
702
810
|
}
|
|
703
811
|
|
|
704
|
-
// Use exact un-clamped element position
|
|
705
|
-
const dx = Math.round(rect.left);
|
|
706
|
-
const dy = Math.round(rect.top);
|
|
812
|
+
// Use exact un-clamped element position in physical canvas pixels
|
|
813
|
+
const dx = Math.round(rect.left * dpr);
|
|
814
|
+
const dy = Math.round(rect.top * dpr);
|
|
707
815
|
|
|
708
816
|
const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
|
|
709
817
|
const srcImg = sourceCtx.getImageData(dx, dy, w, h);
|
|
@@ -773,7 +881,7 @@ function renderIsolatedController(ctrl, rect) {
|
|
|
773
881
|
ctrl._canvas.style.opacity = '1';
|
|
774
882
|
}
|
|
775
883
|
|
|
776
|
-
function areRectsTouching(r1, r2, threshold =
|
|
884
|
+
function areRectsTouching(r1, r2, threshold = 24) {
|
|
777
885
|
const dx = Math.max(
|
|
778
886
|
0,
|
|
779
887
|
Math.abs(r1.left + r1.width * 0.5 - (r2.left + r2.width * 0.5)) -
|
|
@@ -843,7 +951,7 @@ function renderAllActiveLenses() {
|
|
|
843
951
|
if (!visible[i].ctrl.options.fusion) continue;
|
|
844
952
|
for (let j = i + 1; j < n; j++) {
|
|
845
953
|
if (!visible[j].ctrl.options.fusion) continue;
|
|
846
|
-
if (areRectsTouching(visible[i].rect, visible[j].rect,
|
|
954
|
+
if (areRectsTouching(visible[i].rect, visible[j].rect, 24)) {
|
|
847
955
|
union(i, j);
|
|
848
956
|
}
|
|
849
957
|
}
|
|
@@ -861,21 +969,13 @@ function renderAllActiveLenses() {
|
|
|
861
969
|
for (const group of clusters.values()) {
|
|
862
970
|
if (group.length === 1) {
|
|
863
971
|
const { ctrl, rect } = group[0];
|
|
864
|
-
if (ctrl._savedShadow && ctrl.element.style.boxShadow === 'none') {
|
|
865
|
-
ctrl.element.style.boxShadow = ctrl._savedInlineShadow || '';
|
|
866
|
-
}
|
|
867
972
|
if (rect.bottom > 1 && rect.top < vh) {
|
|
868
973
|
renderIsolatedController(ctrl, rect);
|
|
869
974
|
}
|
|
870
975
|
} else {
|
|
871
976
|
hasFusedCluster = true;
|
|
872
|
-
let anyHasShadow = false;
|
|
873
977
|
for (const item of group) {
|
|
874
978
|
item.ctrl._canvas.style.opacity = '0';
|
|
875
|
-
if (item.ctrl._savedShadow) {
|
|
876
|
-
anyHasShadow = true;
|
|
877
|
-
item.ctrl.element.style.boxShadow = 'none';
|
|
878
|
-
}
|
|
879
979
|
}
|
|
880
980
|
const primaryCtrl = group[group.length - 1].ctrl;
|
|
881
981
|
const sourceCtx = primaryCtrl.options.frosted > 0 ? blurCtx : stageCtx;
|
|
@@ -886,28 +986,40 @@ function renderAllActiveLenses() {
|
|
|
886
986
|
h: rect.height,
|
|
887
987
|
borderRadius: ctrl._getRadius(),
|
|
888
988
|
fusion: true,
|
|
889
|
-
tint: ctrl._tint
|
|
989
|
+
tint: ctrl._tint,
|
|
990
|
+
dpr: ctrl.options.dpr
|
|
890
991
|
}));
|
|
891
992
|
|
|
993
|
+
const maxClusterWidth = Math.max(...group.map(g => g.rect.width));
|
|
994
|
+
const dpr = getDevicePixelRatio(primaryCtrl.options.dpr, maxClusterWidth);
|
|
892
995
|
const box = renderFusedCluster(
|
|
893
996
|
clusterOverlayCtx,
|
|
894
997
|
sourceCtx,
|
|
895
998
|
elements,
|
|
896
999
|
{
|
|
897
1000
|
...primaryCtrl.options,
|
|
898
|
-
fusionDistance:
|
|
1001
|
+
fusionDistance: 24
|
|
899
1002
|
},
|
|
900
|
-
|
|
1003
|
+
dpr
|
|
901
1004
|
);
|
|
902
1005
|
|
|
903
1006
|
if (box && clusterOverlayCanvas) {
|
|
904
1007
|
clusterOverlayCanvas.style.display = 'block';
|
|
905
|
-
clusterOverlayCanvas.style.filter =
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1008
|
+
clusterOverlayCanvas.style.filter = 'none';
|
|
1009
|
+
|
|
1010
|
+
// Match z-index to highest element in the cluster so it never pops above floating menus/controls
|
|
1011
|
+
let maxZ = 20;
|
|
1012
|
+
for (const item of group) {
|
|
1013
|
+
if (item.ctrl && item.ctrl.element) {
|
|
1014
|
+
const z = parseInt(window.getComputedStyle(item.ctrl.element).zIndex, 10);
|
|
1015
|
+
if (!Number.isNaN(z) && z > maxZ) maxZ = z;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
clusterOverlayCanvas.style.zIndex = String(maxZ);
|
|
1019
|
+
|
|
1020
|
+
clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX / dpr}px, ${box.cMinY / dpr}px, 0)`;
|
|
1021
|
+
clusterOverlayCanvas.style.width = `${box.boxW / dpr}px`;
|
|
1022
|
+
clusterOverlayCanvas.style.height = `${box.boxH / dpr}px`;
|
|
911
1023
|
}
|
|
912
1024
|
}
|
|
913
1025
|
}
|
|
@@ -1077,11 +1189,11 @@ const LiquidGlassController = exports.LiquidGlassController = class LiquidGlassC
|
|
|
1077
1189
|
changed = true;
|
|
1078
1190
|
}
|
|
1079
1191
|
|
|
1080
|
-
for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion']) {
|
|
1192
|
+
for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion', 'dpr']) {
|
|
1081
1193
|
if (merged[key] !== this.options[key]) {
|
|
1082
1194
|
this.options[key] = merged[key];
|
|
1083
1195
|
changed = true;
|
|
1084
|
-
if (key === 'rim' || key === 'ior' || key === 'height') {
|
|
1196
|
+
if (key === 'rim' || key === 'ior' || key === 'height' || key === 'dpr') {
|
|
1085
1197
|
lutChanged = true;
|
|
1086
1198
|
}
|
|
1087
1199
|
}
|
package/dist/core.mjs
CHANGED
|
@@ -13,14 +13,16 @@ export const OPTICAL_DEFAULTS = Object.freeze({
|
|
|
13
13
|
ior: 1.46,
|
|
14
14
|
height: 18,
|
|
15
15
|
frosted: 0,
|
|
16
|
-
fusion: true
|
|
16
|
+
fusion: true,
|
|
17
|
+
dpr: 0
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
export const OPTICAL_LIMITS = Object.freeze({
|
|
20
21
|
rim: Object.freeze({ min: 4, max: 48 }),
|
|
21
22
|
ior: Object.freeze({ min: 1.05, max: 2.20 }),
|
|
22
23
|
height: Object.freeze({ min: 2, max: 50 }),
|
|
23
|
-
frosted: Object.freeze({ min: 0, max: 32 })
|
|
24
|
+
frosted: Object.freeze({ min: 0, max: 32 }),
|
|
25
|
+
dpr: Object.freeze({ min: 0.5, max: 3.0 })
|
|
24
26
|
});
|
|
25
27
|
|
|
26
28
|
function clampNumber(val, fallback, min, max) {
|
|
@@ -57,7 +59,11 @@ export function normalizeOptions(options = {}) {
|
|
|
57
59
|
OPTICAL_LIMITS.frosted.min,
|
|
58
60
|
OPTICAL_LIMITS.frosted.max
|
|
59
61
|
),
|
|
60
|
-
fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion
|
|
62
|
+
fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion,
|
|
63
|
+
dpr:
|
|
64
|
+
typeof options.dpr === 'number' && options.dpr > 0
|
|
65
|
+
? clampNumber(options.dpr, 1, OPTICAL_LIMITS.dpr.min, OPTICAL_LIMITS.dpr.max)
|
|
66
|
+
: 0
|
|
61
67
|
};
|
|
62
68
|
}
|
|
63
69
|
|
|
@@ -221,7 +227,7 @@ export function buildOpticalMapURI(width, height, borderRadius, rawOptions = {})
|
|
|
221
227
|
*/
|
|
222
228
|
export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dpr = 1) {
|
|
223
229
|
const options = normalizeOptions(rawOptions);
|
|
224
|
-
const fusionSpan = options.fusion ? (rawOptions.fusionDistance ??
|
|
230
|
+
const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 24) * dpr : 0;
|
|
225
231
|
const rimPx = Math.max(1, options.rim * dpr);
|
|
226
232
|
const ior = options.ior;
|
|
227
233
|
const heightPx = options.height * dpr;
|
|
@@ -259,19 +265,26 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
|
|
|
259
265
|
const dstData = dstImg.data;
|
|
260
266
|
|
|
261
267
|
const distGrid = new Float32Array(boxW * boxH);
|
|
262
|
-
const prep = elements.map((b) =>
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
268
|
+
const prep = elements.map((b) => {
|
|
269
|
+
const halfW = b.w * 0.5 * dpr;
|
|
270
|
+
const halfH = b.h * 0.5 * dpr;
|
|
271
|
+
const rad = (b.borderRadius ?? 0) * dpr;
|
|
272
|
+
const r = Math.min(rad, halfW, halfH);
|
|
273
|
+
const isCircle = b.shape === 'circle' || (Math.abs(halfW - halfH) < 1.0 && Math.abs(r - halfW) < 1.0);
|
|
274
|
+
return {
|
|
275
|
+
cx: (b.x + b.w * 0.5) * dpr,
|
|
276
|
+
cy: (b.y + b.h * 0.5) * dpr,
|
|
277
|
+
halfW,
|
|
278
|
+
halfH,
|
|
279
|
+
r,
|
|
280
|
+
isCircle,
|
|
281
|
+
fusion: b.fusion !== undefined ? Boolean(b.fusion) : options.fusion,
|
|
282
|
+
tintR: b.tint ? b.tint[0] : 15,
|
|
283
|
+
tintG: b.tint ? b.tint[1] : 23,
|
|
284
|
+
tintB: b.tint ? b.tint[2] : 42,
|
|
285
|
+
tintA: b.tint ? b.tint[3] : 0.35
|
|
286
|
+
};
|
|
287
|
+
});
|
|
275
288
|
|
|
276
289
|
const primary = prep[0];
|
|
277
290
|
const tR = primary.tintR;
|
|
@@ -279,8 +292,10 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
|
|
|
279
292
|
const tB = primary.tintB;
|
|
280
293
|
const tA = primary.tintA;
|
|
281
294
|
const invTA = 1.0 - tA;
|
|
295
|
+
const span = fusionSpan;
|
|
296
|
+
const invSpan = span > 0.001 ? 1.0 / span : 0;
|
|
282
297
|
|
|
283
|
-
// Pass 1: Boundary evaluation across cluster bounding box
|
|
298
|
+
// Pass 1: Boundary evaluation across cluster bounding box (Inlined fast LUT math)
|
|
284
299
|
for (let y = 0; y < boxH; y++) {
|
|
285
300
|
const py = cMinY + y;
|
|
286
301
|
const row = y * boxW;
|
|
@@ -289,22 +304,36 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
|
|
|
289
304
|
let dCluster = Infinity;
|
|
290
305
|
for (let i = 0; i < prep.length; i++) {
|
|
291
306
|
const b = prep[i];
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
307
|
+
let dShape;
|
|
308
|
+
if (b.isCircle) {
|
|
309
|
+
dShape = Math.hypot(px - b.cx, py - b.cy) - b.halfW;
|
|
310
|
+
} else {
|
|
311
|
+
const qx = Math.abs(px - b.cx) - b.halfW + b.r;
|
|
312
|
+
const qy = Math.abs(py - b.cy) - b.halfH + b.r;
|
|
313
|
+
dShape = Math.min(Math.max(qx, qy), 0.0) + Math.hypot(Math.max(qx, 0.0), Math.max(qy, 0.0)) - b.r;
|
|
314
|
+
}
|
|
315
|
+
|
|
295
316
|
if (i === 0) {
|
|
296
317
|
dCluster = dShape;
|
|
297
|
-
} else if (b.fusion &&
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
318
|
+
} else if (b.fusion && span > 0.001) {
|
|
319
|
+
const diff = dCluster > dShape ? dCluster - dShape : dShape - dCluster;
|
|
320
|
+
if (diff < span) {
|
|
321
|
+
const pos = diff * invSpan * 255.0;
|
|
322
|
+
const pIdx = Math.min(254, pos | 0);
|
|
323
|
+
const wWeight = _T3[pIdx] + (_T3[pIdx + 1] - _T3[pIdx]) * (pos - pIdx);
|
|
324
|
+
dCluster = (dCluster < dShape ? dCluster : dShape) - wWeight * span;
|
|
325
|
+
} else if (dShape < dCluster) {
|
|
326
|
+
dCluster = dShape;
|
|
327
|
+
}
|
|
328
|
+
} else if (dShape < dCluster) {
|
|
329
|
+
dCluster = dShape;
|
|
301
330
|
}
|
|
302
331
|
}
|
|
303
332
|
distGrid[row + x] = dCluster;
|
|
304
333
|
}
|
|
305
334
|
}
|
|
306
335
|
|
|
307
|
-
// Pass 2:
|
|
336
|
+
// Pass 2: Fast vectorized lookup from distGrid + tabular chromatic shift (_T4)
|
|
308
337
|
for (let y = 1; y < boxH - 1; y++) {
|
|
309
338
|
const row = y * boxW;
|
|
310
339
|
for (let x = 1; x < boxW - 1; x++) {
|
|
@@ -325,8 +354,11 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
|
|
|
325
354
|
const innerDist = -d;
|
|
326
355
|
if (innerDist < rimPx) {
|
|
327
356
|
const u = innerDist / rimPx;
|
|
328
|
-
const
|
|
329
|
-
|
|
357
|
+
const posU = u * 255.0;
|
|
358
|
+
const uIdx = Math.min(254, posU | 0);
|
|
359
|
+
const uFrac = posU - uIdx;
|
|
360
|
+
const gain = _T0[uIdx] + (_T0[uIdx + 1] - _T0[uIdx]) * uFrac;
|
|
361
|
+
disp = _T4[uIdx] + (_T4[uIdx + 1] - _T4[uIdx]) * uFrac;
|
|
330
362
|
|
|
331
363
|
const dx = distGrid[row + x + 1] - distGrid[row + x - 1];
|
|
332
364
|
const dy = distGrid[(y + 1) * boxW + x] - distGrid[(y - 1) * boxW + x];
|
|
@@ -339,7 +371,8 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
|
|
|
339
371
|
offY = Math.round(-gy * shift);
|
|
340
372
|
|
|
341
373
|
if (gy < -0.2) {
|
|
342
|
-
|
|
374
|
+
const sVal = _T2[uIdx] + (_T2[uIdx + 1] - _T2[uIdx]) * uFrac;
|
|
375
|
+
sheen = Math.round(sVal * (-gy) * 0.78);
|
|
343
376
|
}
|
|
344
377
|
} else {
|
|
345
378
|
// Fast interior path: zero displacement inside flat region (-d >= rimPx)
|
|
@@ -354,7 +387,9 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
|
|
|
354
387
|
|
|
355
388
|
const absD = Math.abs(d);
|
|
356
389
|
if (absD < 3.0) {
|
|
357
|
-
|
|
390
|
+
const posG = Math.min(254, absD * 0.33 * 255.0);
|
|
391
|
+
const gIdx = posG | 0;
|
|
392
|
+
glint = Math.round((_T1[gIdx] + (_T1[gIdx + 1] - _T1[gIdx]) * (posG - gIdx)) * 0.65);
|
|
358
393
|
}
|
|
359
394
|
|
|
360
395
|
const sxG = Math.max(0, Math.min(boxW - 1, x + offX));
|
|
@@ -448,11 +483,27 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
|
|
|
448
483
|
return fallback;
|
|
449
484
|
}
|
|
450
485
|
|
|
486
|
+
function getDevicePixelRatio(customDpr = 0, elementWidth = 0) {
|
|
487
|
+
if (typeof customDpr === 'number' && customDpr > 0) {
|
|
488
|
+
return Math.max(0.5, Math.min(3.0, customDpr));
|
|
489
|
+
}
|
|
490
|
+
if (typeof window === 'undefined') return 1;
|
|
491
|
+
const sysDpr = window.devicePixelRatio || 1;
|
|
492
|
+
// Automatically use 1.0 for wide elements (headers, banners >= 700px) to guarantee 120 FPS
|
|
493
|
+
if (elementWidth >= 700) {
|
|
494
|
+
return 1;
|
|
495
|
+
}
|
|
496
|
+
return Math.min(sysDpr, 1.5);
|
|
497
|
+
}
|
|
498
|
+
|
|
451
499
|
function ensureStageBuffers() {
|
|
452
500
|
if (typeof document === 'undefined') return false;
|
|
501
|
+
const dpr = getDevicePixelRatio();
|
|
453
502
|
const docEl = document.documentElement;
|
|
454
503
|
const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
|
|
455
504
|
const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
|
|
505
|
+
const bufW = Math.round(vw * dpr);
|
|
506
|
+
const bufH = Math.round(vh * dpr);
|
|
456
507
|
|
|
457
508
|
if (!stageCanvas) {
|
|
458
509
|
stageCanvas = document.createElement('canvas');
|
|
@@ -461,11 +512,11 @@ function ensureStageBuffers() {
|
|
|
461
512
|
blurCtx = blurCanvas.getContext('2d', { willReadFrequently: true });
|
|
462
513
|
}
|
|
463
514
|
|
|
464
|
-
if (stageCanvas.width !==
|
|
465
|
-
stageCanvas.width =
|
|
466
|
-
stageCanvas.height =
|
|
467
|
-
blurCanvas.width =
|
|
468
|
-
blurCanvas.height =
|
|
515
|
+
if (stageCanvas.width !== bufW || stageCanvas.height !== bufH) {
|
|
516
|
+
stageCanvas.width = bufW;
|
|
517
|
+
stageCanvas.height = bufH;
|
|
518
|
+
blurCanvas.width = bufW;
|
|
519
|
+
blurCanvas.height = bufH;
|
|
469
520
|
}
|
|
470
521
|
|
|
471
522
|
if (!clusterOverlayCanvas) {
|
|
@@ -492,14 +543,20 @@ function ensureStageBuffers() {
|
|
|
492
543
|
*/
|
|
493
544
|
function captureViewportStage(neededBlur) {
|
|
494
545
|
if (!ensureStageBuffers()) return;
|
|
495
|
-
const
|
|
496
|
-
const
|
|
546
|
+
const dpr = getDevicePixelRatio();
|
|
547
|
+
const bw = stageCanvas.width;
|
|
548
|
+
const bh = stageCanvas.height;
|
|
549
|
+
const vw = Math.round(bw / dpr);
|
|
550
|
+
const vh = Math.round(bh / dpr);
|
|
497
551
|
|
|
498
|
-
|
|
499
|
-
stageCtx.fillStyle = '#040814';
|
|
500
|
-
stageCtx.fillRect(0, 0, vw, vh);
|
|
552
|
+
stageCtx.save();
|
|
501
553
|
|
|
502
|
-
//
|
|
554
|
+
// 1. Base page background (detect light/dark body background)
|
|
555
|
+
const bodyBg = typeof document !== 'undefined' ? window.getComputedStyle(document.body).backgroundColor : '';
|
|
556
|
+
stageCtx.fillStyle = bodyBg && bodyBg !== 'transparent' && bodyBg !== 'rgba(0, 0, 0, 0)' ? bodyBg : '#040814';
|
|
557
|
+
stageCtx.fillRect(0, 0, bw, bh);
|
|
558
|
+
|
|
559
|
+
// 2. Copy any background 2D canvas using physical buffer pixels
|
|
503
560
|
const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
|
|
504
561
|
for (let i = 0; i < bgCanvases.length; i++) {
|
|
505
562
|
const c = bgCanvases[i];
|
|
@@ -507,7 +564,13 @@ function captureViewportStage(neededBlur) {
|
|
|
507
564
|
try {
|
|
508
565
|
const cr = c.getBoundingClientRect();
|
|
509
566
|
if (cr.width > 0 && cr.height > 0) {
|
|
510
|
-
stageCtx.drawImage(
|
|
567
|
+
stageCtx.drawImage(
|
|
568
|
+
c,
|
|
569
|
+
Math.round(cr.left * dpr),
|
|
570
|
+
Math.round(cr.top * dpr),
|
|
571
|
+
Math.round(cr.width * dpr),
|
|
572
|
+
Math.round(cr.height * dpr)
|
|
573
|
+
);
|
|
511
574
|
}
|
|
512
575
|
} catch {
|
|
513
576
|
// Ignore cross-origin canvas if any
|
|
@@ -515,6 +578,9 @@ function captureViewportStage(neededBlur) {
|
|
|
515
578
|
}
|
|
516
579
|
}
|
|
517
580
|
|
|
581
|
+
// Scale context for DOM elements rendered in CSS pixels
|
|
582
|
+
stageCtx.scale(dpr, dpr);
|
|
583
|
+
|
|
518
584
|
// 3. Draw visible block surfaces (cards, buttons, pills, inputs) inside <main> and <footer>
|
|
519
585
|
const surfaceEls = document.querySelectorAll(
|
|
520
586
|
'main div.rounded-2xl, main div.rounded-xl, main a.rounded-2xl, main a.rounded-xl, main button, main a.inline-flex, main input, main textarea, main span.rounded-lg, main span.rounded-full'
|
|
@@ -668,13 +734,53 @@ function captureViewportStage(neededBlur) {
|
|
|
668
734
|
stageCtx.restore();
|
|
669
735
|
}
|
|
670
736
|
|
|
671
|
-
|
|
737
|
+
stageCtx.restore();
|
|
738
|
+
|
|
739
|
+
let supportsCanvasFilterBlur = null;
|
|
740
|
+
function checkCanvasFilterBlur() {
|
|
741
|
+
if (supportsCanvasFilterBlur !== null) return supportsCanvasFilterBlur;
|
|
742
|
+
if (typeof document === 'undefined') return false;
|
|
743
|
+
try {
|
|
744
|
+
const testC = document.createElement('canvas');
|
|
745
|
+
testC.width = 4;
|
|
746
|
+
testC.height = 4;
|
|
747
|
+
const testCtx = testC.getContext('2d');
|
|
748
|
+
if (!testCtx || typeof testCtx.filter !== 'string') {
|
|
749
|
+
return (supportsCanvasFilterBlur = false);
|
|
750
|
+
}
|
|
751
|
+
testCtx.fillStyle = '#000';
|
|
752
|
+
testCtx.fillRect(0, 0, 4, 4);
|
|
753
|
+
testCtx.filter = 'blur(2px)';
|
|
754
|
+
testCtx.fillStyle = '#fff';
|
|
755
|
+
testCtx.fillRect(0, 0, 2, 2);
|
|
756
|
+
testCtx.filter = 'none';
|
|
757
|
+
const imgData = testCtx.getImageData(0, 0, 4, 4).data;
|
|
758
|
+
supportsCanvasFilterBlur = imgData[15 * 4] > 0 || imgData[14 * 4] > 0 || imgData[11 * 4] > 0;
|
|
759
|
+
return supportsCanvasFilterBlur;
|
|
760
|
+
} catch {
|
|
761
|
+
return (supportsCanvasFilterBlur = false);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// 5. Blur buffer for frosted glass (Robust across all mobile WebKit & Blink engines)
|
|
672
766
|
if (neededBlur > 0) {
|
|
673
767
|
lastFrostedRadius = neededBlur;
|
|
674
|
-
blurCtx.clearRect(0, 0,
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
768
|
+
blurCtx.clearRect(0, 0, bw, bh);
|
|
769
|
+
|
|
770
|
+
if (checkCanvasFilterBlur()) {
|
|
771
|
+
blurCtx.filter = `blur(${Math.round(neededBlur * dpr)}px)`;
|
|
772
|
+
blurCtx.drawImage(stageCanvas, 0, 0);
|
|
773
|
+
blurCtx.filter = 'none';
|
|
774
|
+
} else {
|
|
775
|
+
// Hardware-accelerated multi-step pyramid downscale/upscale blur for iOS Safari and mobile WebKit
|
|
776
|
+
const factor = Math.max(0.04, 1.0 / (1.0 + neededBlur * 0.45));
|
|
777
|
+
const pw = Math.max(4, Math.round(bw * factor));
|
|
778
|
+
const ph = Math.max(4, Math.round(bh * factor));
|
|
779
|
+
blurCtx.imageSmoothingEnabled = true;
|
|
780
|
+
blurCtx.imageSmoothingQuality = 'high';
|
|
781
|
+
blurCtx.drawImage(stageCanvas, 0, 0, pw, ph);
|
|
782
|
+
blurCtx.drawImage(blurCanvas, 0, 0, pw, ph, 0, 0, bw, bh);
|
|
783
|
+
}
|
|
678
784
|
}
|
|
679
785
|
}
|
|
680
786
|
|
|
@@ -682,17 +788,19 @@ function captureViewportStage(neededBlur) {
|
|
|
682
788
|
* Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
|
|
683
789
|
*/
|
|
684
790
|
function renderIsolatedController(ctrl, rect) {
|
|
685
|
-
const
|
|
686
|
-
const
|
|
791
|
+
const dpr = getDevicePixelRatio(ctrl.options.dpr, rect.width);
|
|
792
|
+
const w = Math.max(16, Math.round(rect.width * dpr));
|
|
793
|
+
const h = Math.max(16, Math.round(rect.height * dpr));
|
|
687
794
|
const rad = ctrl._getRadius();
|
|
795
|
+
const radPx = Math.min(rad * dpr, w * 0.5, h * 0.5);
|
|
688
796
|
|
|
689
797
|
if (
|
|
690
798
|
!ctrl._lut ||
|
|
691
799
|
ctrl._lut.w !== w ||
|
|
692
800
|
ctrl._lut.h !== h ||
|
|
693
|
-
Math.abs(ctrl._lut.rad -
|
|
801
|
+
Math.abs(ctrl._lut.rad - radPx) > 1.0
|
|
694
802
|
) {
|
|
695
|
-
ctrl._lut = buildElementLUT(w, h, rad, ctrl.options,
|
|
803
|
+
ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, dpr);
|
|
696
804
|
}
|
|
697
805
|
|
|
698
806
|
const lut = ctrl._lut;
|
|
@@ -701,9 +809,9 @@ function renderIsolatedController(ctrl, rect) {
|
|
|
701
809
|
ctrl._canvas.height = h;
|
|
702
810
|
}
|
|
703
811
|
|
|
704
|
-
// Use exact un-clamped element position
|
|
705
|
-
const dx = Math.round(rect.left);
|
|
706
|
-
const dy = Math.round(rect.top);
|
|
812
|
+
// Use exact un-clamped element position in physical canvas pixels
|
|
813
|
+
const dx = Math.round(rect.left * dpr);
|
|
814
|
+
const dy = Math.round(rect.top * dpr);
|
|
707
815
|
|
|
708
816
|
const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
|
|
709
817
|
const srcImg = sourceCtx.getImageData(dx, dy, w, h);
|
|
@@ -773,7 +881,7 @@ function renderIsolatedController(ctrl, rect) {
|
|
|
773
881
|
ctrl._canvas.style.opacity = '1';
|
|
774
882
|
}
|
|
775
883
|
|
|
776
|
-
function areRectsTouching(r1, r2, threshold =
|
|
884
|
+
function areRectsTouching(r1, r2, threshold = 24) {
|
|
777
885
|
const dx = Math.max(
|
|
778
886
|
0,
|
|
779
887
|
Math.abs(r1.left + r1.width * 0.5 - (r2.left + r2.width * 0.5)) -
|
|
@@ -843,7 +951,7 @@ function renderAllActiveLenses() {
|
|
|
843
951
|
if (!visible[i].ctrl.options.fusion) continue;
|
|
844
952
|
for (let j = i + 1; j < n; j++) {
|
|
845
953
|
if (!visible[j].ctrl.options.fusion) continue;
|
|
846
|
-
if (areRectsTouching(visible[i].rect, visible[j].rect,
|
|
954
|
+
if (areRectsTouching(visible[i].rect, visible[j].rect, 24)) {
|
|
847
955
|
union(i, j);
|
|
848
956
|
}
|
|
849
957
|
}
|
|
@@ -861,21 +969,13 @@ function renderAllActiveLenses() {
|
|
|
861
969
|
for (const group of clusters.values()) {
|
|
862
970
|
if (group.length === 1) {
|
|
863
971
|
const { ctrl, rect } = group[0];
|
|
864
|
-
if (ctrl._savedShadow && ctrl.element.style.boxShadow === 'none') {
|
|
865
|
-
ctrl.element.style.boxShadow = ctrl._savedInlineShadow || '';
|
|
866
|
-
}
|
|
867
972
|
if (rect.bottom > 1 && rect.top < vh) {
|
|
868
973
|
renderIsolatedController(ctrl, rect);
|
|
869
974
|
}
|
|
870
975
|
} else {
|
|
871
976
|
hasFusedCluster = true;
|
|
872
|
-
let anyHasShadow = false;
|
|
873
977
|
for (const item of group) {
|
|
874
978
|
item.ctrl._canvas.style.opacity = '0';
|
|
875
|
-
if (item.ctrl._savedShadow) {
|
|
876
|
-
anyHasShadow = true;
|
|
877
|
-
item.ctrl.element.style.boxShadow = 'none';
|
|
878
|
-
}
|
|
879
979
|
}
|
|
880
980
|
const primaryCtrl = group[group.length - 1].ctrl;
|
|
881
981
|
const sourceCtx = primaryCtrl.options.frosted > 0 ? blurCtx : stageCtx;
|
|
@@ -886,28 +986,40 @@ function renderAllActiveLenses() {
|
|
|
886
986
|
h: rect.height,
|
|
887
987
|
borderRadius: ctrl._getRadius(),
|
|
888
988
|
fusion: true,
|
|
889
|
-
tint: ctrl._tint
|
|
989
|
+
tint: ctrl._tint,
|
|
990
|
+
dpr: ctrl.options.dpr
|
|
890
991
|
}));
|
|
891
992
|
|
|
993
|
+
const maxClusterWidth = Math.max(...group.map(g => g.rect.width));
|
|
994
|
+
const dpr = getDevicePixelRatio(primaryCtrl.options.dpr, maxClusterWidth);
|
|
892
995
|
const box = renderFusedCluster(
|
|
893
996
|
clusterOverlayCtx,
|
|
894
997
|
sourceCtx,
|
|
895
998
|
elements,
|
|
896
999
|
{
|
|
897
1000
|
...primaryCtrl.options,
|
|
898
|
-
fusionDistance:
|
|
1001
|
+
fusionDistance: 24
|
|
899
1002
|
},
|
|
900
|
-
|
|
1003
|
+
dpr
|
|
901
1004
|
);
|
|
902
1005
|
|
|
903
1006
|
if (box && clusterOverlayCanvas) {
|
|
904
1007
|
clusterOverlayCanvas.style.display = 'block';
|
|
905
|
-
clusterOverlayCanvas.style.filter =
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1008
|
+
clusterOverlayCanvas.style.filter = 'none';
|
|
1009
|
+
|
|
1010
|
+
// Match z-index to highest element in the cluster so it never pops above floating menus/controls
|
|
1011
|
+
let maxZ = 20;
|
|
1012
|
+
for (const item of group) {
|
|
1013
|
+
if (item.ctrl && item.ctrl.element) {
|
|
1014
|
+
const z = parseInt(window.getComputedStyle(item.ctrl.element).zIndex, 10);
|
|
1015
|
+
if (!Number.isNaN(z) && z > maxZ) maxZ = z;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
clusterOverlayCanvas.style.zIndex = String(maxZ);
|
|
1019
|
+
|
|
1020
|
+
clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX / dpr}px, ${box.cMinY / dpr}px, 0)`;
|
|
1021
|
+
clusterOverlayCanvas.style.width = `${box.boxW / dpr}px`;
|
|
1022
|
+
clusterOverlayCanvas.style.height = `${box.boxH / dpr}px`;
|
|
911
1023
|
}
|
|
912
1024
|
}
|
|
913
1025
|
}
|
|
@@ -1077,11 +1189,11 @@ export class LiquidGlassController {
|
|
|
1077
1189
|
changed = true;
|
|
1078
1190
|
}
|
|
1079
1191
|
|
|
1080
|
-
for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion']) {
|
|
1192
|
+
for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion', 'dpr']) {
|
|
1081
1193
|
if (merged[key] !== this.options[key]) {
|
|
1082
1194
|
this.options[key] = merged[key];
|
|
1083
1195
|
changed = true;
|
|
1084
|
-
if (key === 'rim' || key === 'ior' || key === 'height') {
|
|
1196
|
+
if (key === 'rim' || key === 'ior' || key === 'height' || key === 'dpr') {
|
|
1085
1197
|
lutChanged = true;
|
|
1086
1198
|
}
|
|
1087
1199
|
}
|
package/dist/react.cjs
CHANGED
|
@@ -31,7 +31,8 @@ exports.useLiquidGlass = function useLiquidGlass(options = {}) {
|
|
|
31
31
|
height: options.height,
|
|
32
32
|
frosted: options.frosted,
|
|
33
33
|
fusion: options.fusion,
|
|
34
|
-
enabled: options.enabled
|
|
34
|
+
enabled: options.enabled,
|
|
35
|
+
dpr: options.dpr
|
|
35
36
|
});
|
|
36
37
|
}
|
|
37
38
|
}, [
|
|
@@ -40,7 +41,8 @@ exports.useLiquidGlass = function useLiquidGlass(options = {}) {
|
|
|
40
41
|
options.height,
|
|
41
42
|
options.frosted,
|
|
42
43
|
options.fusion,
|
|
43
|
-
options.enabled
|
|
44
|
+
options.enabled,
|
|
45
|
+
options.dpr
|
|
44
46
|
]);
|
|
45
47
|
|
|
46
48
|
return ref;
|
|
@@ -57,6 +59,7 @@ const LiquidGlass = exports.LiquidGlass = forwardRef(function LiquidGlass(
|
|
|
57
59
|
frosted = OPTICAL_DEFAULTS.frosted,
|
|
58
60
|
fusion = OPTICAL_DEFAULTS.fusion,
|
|
59
61
|
enabled = true,
|
|
62
|
+
dpr = OPTICAL_DEFAULTS.dpr,
|
|
60
63
|
as: Component = 'div',
|
|
61
64
|
style,
|
|
62
65
|
className,
|
|
@@ -71,7 +74,8 @@ const LiquidGlass = exports.LiquidGlass = forwardRef(function LiquidGlass(
|
|
|
71
74
|
height,
|
|
72
75
|
frosted,
|
|
73
76
|
fusion,
|
|
74
|
-
enabled
|
|
77
|
+
enabled,
|
|
78
|
+
dpr
|
|
75
79
|
});
|
|
76
80
|
|
|
77
81
|
const setRefs = useCallback(
|
package/dist/react.mjs
CHANGED
|
@@ -30,7 +30,8 @@ export function useLiquidGlass(options = {}) {
|
|
|
30
30
|
height: options.height,
|
|
31
31
|
frosted: options.frosted,
|
|
32
32
|
fusion: options.fusion,
|
|
33
|
-
enabled: options.enabled
|
|
33
|
+
enabled: options.enabled,
|
|
34
|
+
dpr: options.dpr
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
}, [
|
|
@@ -39,7 +40,8 @@ export function useLiquidGlass(options = {}) {
|
|
|
39
40
|
options.height,
|
|
40
41
|
options.frosted,
|
|
41
42
|
options.fusion,
|
|
42
|
-
options.enabled
|
|
43
|
+
options.enabled,
|
|
44
|
+
options.dpr
|
|
43
45
|
]);
|
|
44
46
|
|
|
45
47
|
return ref;
|
|
@@ -56,6 +58,7 @@ export const LiquidGlass = forwardRef(function LiquidGlass(
|
|
|
56
58
|
frosted = OPTICAL_DEFAULTS.frosted,
|
|
57
59
|
fusion = OPTICAL_DEFAULTS.fusion,
|
|
58
60
|
enabled = true,
|
|
61
|
+
dpr = OPTICAL_DEFAULTS.dpr,
|
|
59
62
|
as: Component = 'div',
|
|
60
63
|
style,
|
|
61
64
|
className,
|
|
@@ -70,7 +73,8 @@ export const LiquidGlass = forwardRef(function LiquidGlass(
|
|
|
70
73
|
height,
|
|
71
74
|
frosted,
|
|
72
75
|
fusion,
|
|
73
|
-
enabled
|
|
76
|
+
enabled,
|
|
77
|
+
dpr
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
const setRefs = useCallback(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shulya/liquid-glass",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
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",
|
package/types/index.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface LiquidGlassOptions {
|
|
|
30
30
|
*/
|
|
31
31
|
enabled?: boolean;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Optional custom device pixel ratio override (e.g. 1.0 for full-width navbars to maximize FPS, or 1.5–2.0 for high-DPI retina sharpness).
|
|
35
|
+
* Defaults to 0 (automatic: 1.5 for compact UI elements, 1.0 for elements >= 700px wide).
|
|
36
|
+
*/
|
|
37
|
+
dpr?: number;
|
|
38
|
+
|
|
33
39
|
/**
|
|
34
40
|
* Optional border radius override in pixels
|
|
35
41
|
*/
|
|
@@ -42,6 +48,7 @@ export declare const OPTICAL_DEFAULTS: Readonly<{
|
|
|
42
48
|
height: 18;
|
|
43
49
|
frosted: 0;
|
|
44
50
|
fusion: true;
|
|
51
|
+
dpr: 0;
|
|
45
52
|
}>;
|
|
46
53
|
|
|
47
54
|
export declare const OPTICAL_LIMITS: Readonly<{
|
|
@@ -49,6 +56,7 @@ export declare const OPTICAL_LIMITS: Readonly<{
|
|
|
49
56
|
ior: Readonly<{ min: 1.05; max: 2.2 }>;
|
|
50
57
|
height: Readonly<{ min: 2; max: 50 }>;
|
|
51
58
|
frosted: Readonly<{ min: 0; max: 32 }>;
|
|
59
|
+
dpr: Readonly<{ min: 0.5; max: 3.0 }>;
|
|
52
60
|
}>;
|
|
53
61
|
|
|
54
62
|
export declare function normalizeOptions(options?: LiquidGlassOptions): Required<LiquidGlassOptions>;
|