@boyernick/standard-ui-react 0.4.2-canary.60 → 0.4.2-canary.61
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/package.json +1 -1
- package/src/carousel.tsx +1 -13
- package/src/glass.tsx +22 -41
- package/src/lib/lens-glass/config.ts +225 -0
- package/src/lib/lens-glass/renderer.ts +586 -0
- package/src/lib/lens-glass/shader.ts +138 -0
- package/src/lib/media-glass.ts +11 -13
- package/src/modal.tsx +2 -2
- package/src/lib/liquidglass/NOTICE.md +0 -24
- package/src/lib/liquidglass/index.d.ts +0 -45
- package/src/lib/liquidglass/index.js +0 -2862
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import {
|
|
2
|
+
intersect,
|
|
3
|
+
objectFitRect,
|
|
4
|
+
parseConfig,
|
|
5
|
+
resolveGlass,
|
|
6
|
+
samplePadding,
|
|
7
|
+
type Box,
|
|
8
|
+
type GlassConfig,
|
|
9
|
+
type ResolvedGlass,
|
|
10
|
+
} from "./config";
|
|
11
|
+
import { FRAGMENT_SHADER, VERTEX_SHADER } from "./shader";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders StandardUI's glass material onto panes (see ./config).
|
|
15
|
+
*
|
|
16
|
+
* The ground each pane refracts is drawn from what is actually behind it
|
|
17
|
+
* inside the root: images, video frames and canvases at their current
|
|
18
|
+
* positions and crops, over the backgrounds of the root's direct children,
|
|
19
|
+
* over a ground colour (`--glass-ground`, else the nearest opaque ancestor
|
|
20
|
+
* background). There is no DOM-to-image capture, so nothing goes stale: a
|
|
21
|
+
* slide that moves or a video that plays is simply drawn where it is now.
|
|
22
|
+
* Text and other non-media content behind a pane contributes its box's
|
|
23
|
+
* background colour only.
|
|
24
|
+
*
|
|
25
|
+
* All panes on the page share one WebGL context — browsers cap how many a
|
|
26
|
+
* page may hold — and each pane shows its frame in its own 2D canvas.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const OVERFLOW = 12;
|
|
30
|
+
const MAX_SCENE_PIXELS = 1600 * 1600;
|
|
31
|
+
|
|
32
|
+
type Shared = {
|
|
33
|
+
canvas: HTMLCanvasElement;
|
|
34
|
+
gl: WebGLRenderingContext;
|
|
35
|
+
program: WebGLProgram;
|
|
36
|
+
texture: WebGLTexture;
|
|
37
|
+
uniforms: Map<string, WebGLUniformLocation | null>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let shared: Shared | null = null;
|
|
41
|
+
let lost = false;
|
|
42
|
+
|
|
43
|
+
function compile(gl: WebGLRenderingContext, type: number, source: string) {
|
|
44
|
+
const shader = gl.createShader(type);
|
|
45
|
+
if (!shader) throw new Error("Glass: could not create a shader.");
|
|
46
|
+
gl.shaderSource(shader, source);
|
|
47
|
+
gl.compileShader(shader);
|
|
48
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
49
|
+
throw new Error(`Glass: shader failed to compile: ${gl.getShaderInfoLog(shader)}`);
|
|
50
|
+
}
|
|
51
|
+
return shader;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getShared(): Shared {
|
|
55
|
+
if (shared && !lost) return shared;
|
|
56
|
+
const canvas = shared?.canvas ?? document.createElement("canvas");
|
|
57
|
+
const gl = canvas.getContext("webgl", {
|
|
58
|
+
alpha: true,
|
|
59
|
+
premultipliedAlpha: false,
|
|
60
|
+
antialias: false,
|
|
61
|
+
preserveDrawingBuffer: false,
|
|
62
|
+
});
|
|
63
|
+
if (!gl) throw new Error("Glass: WebGL is unavailable.");
|
|
64
|
+
if (!shared) {
|
|
65
|
+
canvas.addEventListener("webglcontextlost", (event) => {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
lost = true;
|
|
68
|
+
});
|
|
69
|
+
canvas.addEventListener("webglcontextrestored", () => {
|
|
70
|
+
lost = false;
|
|
71
|
+
shared = null;
|
|
72
|
+
for (const instance of instances) instance.markChanged();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const program = gl.createProgram();
|
|
77
|
+
if (!program) throw new Error("Glass: could not create a program.");
|
|
78
|
+
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER));
|
|
79
|
+
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER));
|
|
80
|
+
gl.linkProgram(program);
|
|
81
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
82
|
+
throw new Error(`Glass: program failed to link: ${gl.getProgramInfoLog(program)}`);
|
|
83
|
+
}
|
|
84
|
+
gl.useProgram(program);
|
|
85
|
+
|
|
86
|
+
const buffer = gl.createBuffer();
|
|
87
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
88
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
|
89
|
+
const position = gl.getAttribLocation(program, "a_position");
|
|
90
|
+
gl.enableVertexAttribArray(position);
|
|
91
|
+
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
|
92
|
+
|
|
93
|
+
const texture = gl.createTexture();
|
|
94
|
+
if (!texture) throw new Error("Glass: could not create a texture.");
|
|
95
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
96
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
97
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
98
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
99
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
100
|
+
|
|
101
|
+
shared = { canvas, gl, program, texture, uniforms: new Map() };
|
|
102
|
+
lost = false;
|
|
103
|
+
return shared;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function uniform(s: Shared, name: string) {
|
|
107
|
+
if (!s.uniforms.has(name)) s.uniforms.set(name, s.gl.getUniformLocation(s.program, name));
|
|
108
|
+
return s.uniforms.get(name) ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const instances = new Set<{ markChanged(): void }>();
|
|
112
|
+
|
|
113
|
+
// ────────────────────────────────────────────────────────────────
|
|
114
|
+
// Reading what is behind a pane
|
|
115
|
+
// ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const toBox = (rect: DOMRect): Box => ({
|
|
118
|
+
x: rect.left,
|
|
119
|
+
y: rect.top,
|
|
120
|
+
width: rect.width,
|
|
121
|
+
height: rect.height,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const transparent = (color: string) =>
|
|
125
|
+
!color || color === "transparent" || /rgba?\([^)]*,\s*0(\.0+)?\)$/.test(color);
|
|
126
|
+
|
|
127
|
+
/** Media whose pixels a canvas cannot read without tainting it. */
|
|
128
|
+
const tainted = new WeakSet<Element>();
|
|
129
|
+
|
|
130
|
+
function crossOrigin(element: HTMLImageElement | HTMLVideoElement) {
|
|
131
|
+
if (element.crossOrigin) return false;
|
|
132
|
+
const source = element.currentSrc || element.getAttribute("src") || "";
|
|
133
|
+
if (!source || source.startsWith("data:") || source.startsWith("blob:")) return false;
|
|
134
|
+
try {
|
|
135
|
+
return new URL(source, location.href).origin !== location.origin;
|
|
136
|
+
} catch {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function drawable(element: Element): element is HTMLImageElement | HTMLVideoElement | HTMLCanvasElement {
|
|
142
|
+
if (tainted.has(element)) return false;
|
|
143
|
+
if (element instanceof HTMLImageElement) {
|
|
144
|
+
return element.complete && element.naturalWidth > 0 && !crossOrigin(element);
|
|
145
|
+
}
|
|
146
|
+
if (element instanceof HTMLVideoElement) {
|
|
147
|
+
return element.readyState >= 2 && element.videoWidth > 0 && !crossOrigin(element);
|
|
148
|
+
}
|
|
149
|
+
return element instanceof HTMLCanvasElement && element.width > 0 && element.height > 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function naturalSize(element: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement) {
|
|
153
|
+
if (element instanceof HTMLImageElement) return { width: element.naturalWidth, height: element.naturalHeight };
|
|
154
|
+
if (element instanceof HTMLVideoElement) return { width: element.videoWidth, height: element.videoHeight };
|
|
155
|
+
return { width: element.width, height: element.height };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The box a media element is visible through, after every clipping ancestor
|
|
160
|
+
* up to the root, and its opacity after every ancestor's. Null if hidden.
|
|
161
|
+
*/
|
|
162
|
+
function visibility(element: Element, root: Element) {
|
|
163
|
+
let clip: Box | null = toBox(element.getBoundingClientRect());
|
|
164
|
+
let opacity = 1;
|
|
165
|
+
for (let node: Element | null = element; node && node !== root; node = node.parentElement) {
|
|
166
|
+
const style = getComputedStyle(node);
|
|
167
|
+
if (style.display === "none" || style.visibility === "hidden") return null;
|
|
168
|
+
opacity *= Number(style.opacity) || 0;
|
|
169
|
+
if (opacity <= 0.01) return null;
|
|
170
|
+
if (node !== element && (style.overflowX !== "visible" || style.overflowY !== "visible")) {
|
|
171
|
+
clip = clip && intersect(clip, toBox(node.getBoundingClientRect()));
|
|
172
|
+
if (!clip) return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return clip ? { clip, opacity } : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The clip every clipping ancestor imposes, not counting the element's own box. */
|
|
179
|
+
function clipOf(element: Element): Box {
|
|
180
|
+
let clip: Box = { x: -1e6, y: -1e6, width: 2e6, height: 2e6 };
|
|
181
|
+
for (let node = element.parentElement; node; node = node.parentElement) {
|
|
182
|
+
const style = getComputedStyle(node);
|
|
183
|
+
if (style.overflowX !== "visible" || style.overflowY !== "visible") {
|
|
184
|
+
clip = intersect(clip, toBox(node.getBoundingClientRect())) ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
185
|
+
}
|
|
186
|
+
if (node.dataset?.slot === "glass-root") break;
|
|
187
|
+
}
|
|
188
|
+
return clip;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ────────────────────────────────────────────────────────────────
|
|
192
|
+
// Instances
|
|
193
|
+
// ────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
type Pane = {
|
|
196
|
+
element: HTMLElement;
|
|
197
|
+
canvas: HTMLCanvasElement;
|
|
198
|
+
context: CanvasRenderingContext2D;
|
|
199
|
+
restorePosition: string | null;
|
|
200
|
+
hover: number;
|
|
201
|
+
press: number;
|
|
202
|
+
hoverTarget: number;
|
|
203
|
+
pressTarget: number;
|
|
204
|
+
signature: string;
|
|
205
|
+
cleanup: () => void;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export interface LensGlassOptions {
|
|
209
|
+
root: HTMLElement;
|
|
210
|
+
glassElements: HTMLElement[];
|
|
211
|
+
defaults?: GlassConfig;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface LensGlass {
|
|
215
|
+
markChanged(): void;
|
|
216
|
+
destroy(): void;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function createLensGlass({ root, glassElements, defaults = {} }: LensGlassOptions): LensGlass {
|
|
220
|
+
getShared(); // throws if WebGL is unavailable, so the root can fall back
|
|
221
|
+
|
|
222
|
+
const scene = document.createElement("canvas");
|
|
223
|
+
const maybeSceneContext = scene.getContext("2d");
|
|
224
|
+
if (!maybeSceneContext) throw new Error("Glass: 2D canvas is unavailable.");
|
|
225
|
+
const sceneContext: CanvasRenderingContext2D = maybeSceneContext;
|
|
226
|
+
|
|
227
|
+
// If anything below throws, take back what was already added to the DOM so
|
|
228
|
+
// a failed start leaves the panes as they were.
|
|
229
|
+
const injected: Array<{ canvas: HTMLCanvasElement; element: HTMLElement; position: string | null }> = [];
|
|
230
|
+
const undo = () => {
|
|
231
|
+
for (const { canvas, element, position } of injected) {
|
|
232
|
+
canvas.remove();
|
|
233
|
+
if (position !== null) element.style.position = position;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
let panes: Pane[];
|
|
237
|
+
try {
|
|
238
|
+
panes = glassElements.map((element) => {
|
|
239
|
+
const canvas = document.createElement("canvas");
|
|
240
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
241
|
+
canvas.dataset.glassCanvas = "";
|
|
242
|
+
Object.assign(canvas.style, {
|
|
243
|
+
position: "absolute",
|
|
244
|
+
pointerEvents: "none",
|
|
245
|
+
zIndex: "-1",
|
|
246
|
+
});
|
|
247
|
+
element.prepend(canvas);
|
|
248
|
+
const record = { canvas, element, position: null as string | null };
|
|
249
|
+
injected.push(record);
|
|
250
|
+
const context = canvas.getContext("2d");
|
|
251
|
+
if (!context) throw new Error("Glass: 2D canvas is unavailable.");
|
|
252
|
+
|
|
253
|
+
let restorePosition: string | null = null;
|
|
254
|
+
if (getComputedStyle(element).position === "static") {
|
|
255
|
+
restorePosition = element.style.position;
|
|
256
|
+
element.style.position = "relative";
|
|
257
|
+
record.position = restorePosition;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const pane: Pane = {
|
|
261
|
+
element, canvas, context, restorePosition,
|
|
262
|
+
hover: 0, press: 0, hoverTarget: 0, pressTarget: 0, signature: "", cleanup: () => {},
|
|
263
|
+
};
|
|
264
|
+
const enter = () => { pane.hoverTarget = 1; wake(); };
|
|
265
|
+
const leave = () => { pane.hoverTarget = 0; pane.pressTarget = 0; wake(); };
|
|
266
|
+
const down = () => { pane.pressTarget = 1; wake(); };
|
|
267
|
+
const up = () => { pane.pressTarget = 0; wake(); };
|
|
268
|
+
element.addEventListener("pointerenter", enter);
|
|
269
|
+
element.addEventListener("pointerleave", leave);
|
|
270
|
+
element.addEventListener("pointerdown", down);
|
|
271
|
+
element.addEventListener("pointerup", up);
|
|
272
|
+
element.addEventListener("pointercancel", up);
|
|
273
|
+
pane.cleanup = () => {
|
|
274
|
+
element.removeEventListener("pointerenter", enter);
|
|
275
|
+
element.removeEventListener("pointerleave", leave);
|
|
276
|
+
element.removeEventListener("pointerdown", down);
|
|
277
|
+
element.removeEventListener("pointerup", up);
|
|
278
|
+
element.removeEventListener("pointercancel", up);
|
|
279
|
+
};
|
|
280
|
+
return pane;
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
undo();
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const paneSet = new Set(glassElements);
|
|
288
|
+
const insidePane = (node: Element) => {
|
|
289
|
+
for (let n: Element | null = node; n && n !== root; n = n.parentElement) {
|
|
290
|
+
if (paneSet.has(n as HTMLElement)) return true;
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Everything that can paint behind a pane, in document order (a close
|
|
296
|
+
// enough stand-in for paint order): media, and boxes with a background.
|
|
297
|
+
let paintList: Element[] = [];
|
|
298
|
+
let media: Element[] = [];
|
|
299
|
+
const collectMedia = () => {
|
|
300
|
+
paintList = Array.from(root.querySelectorAll("*")).filter((element) => {
|
|
301
|
+
if (paneSet.has(element as HTMLElement) || insidePane(element)) return false;
|
|
302
|
+
if ((element as HTMLElement).dataset?.glassCanvas !== undefined) return false;
|
|
303
|
+
if (element instanceof HTMLImageElement || element instanceof HTMLVideoElement || element instanceof HTMLCanvasElement) return true;
|
|
304
|
+
return !transparent(getComputedStyle(element).backgroundColor);
|
|
305
|
+
});
|
|
306
|
+
media = paintList.filter((element) => !(element instanceof HTMLElement) || element.matches("img, video, canvas"));
|
|
307
|
+
};
|
|
308
|
+
collectMedia();
|
|
309
|
+
|
|
310
|
+
const groundColor = () => {
|
|
311
|
+
const custom = getComputedStyle(root).getPropertyValue("--glass-ground").trim();
|
|
312
|
+
if (custom) return custom;
|
|
313
|
+
for (let node: Element | null = root; node; node = node.parentElement) {
|
|
314
|
+
const color = getComputedStyle(node).backgroundColor;
|
|
315
|
+
if (!transparent(color)) return color;
|
|
316
|
+
}
|
|
317
|
+
return getComputedStyle(document.documentElement).backgroundColor || "#000";
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
let forced = true;
|
|
321
|
+
let visible = true;
|
|
322
|
+
let frame = 0;
|
|
323
|
+
let destroyed = false;
|
|
324
|
+
|
|
325
|
+
const intersection = new IntersectionObserver((entries) => {
|
|
326
|
+
visible = entries.some((entry) => entry.isIntersecting);
|
|
327
|
+
if (visible) wake();
|
|
328
|
+
});
|
|
329
|
+
intersection.observe(root);
|
|
330
|
+
|
|
331
|
+
const mutations = new MutationObserver((records) => {
|
|
332
|
+
// Our own canvases change on every frame we draw; they are not the ground.
|
|
333
|
+
const relevant = records.filter(
|
|
334
|
+
(record) => (record.target as HTMLElement).dataset?.glassCanvas === undefined,
|
|
335
|
+
);
|
|
336
|
+
if (!relevant.length) return;
|
|
337
|
+
// Inline style changes (a carousel's transform while dragging) move
|
|
338
|
+
// things, which the per-frame position check already sees; only a change
|
|
339
|
+
// in what exists or how it is styled means re-listing what paints.
|
|
340
|
+
if (relevant.some((record) => record.type === "childList" || record.attributeName !== "style")) {
|
|
341
|
+
collectMedia();
|
|
342
|
+
}
|
|
343
|
+
forced = true;
|
|
344
|
+
wake();
|
|
345
|
+
});
|
|
346
|
+
mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "data-config", "class", "style"] });
|
|
347
|
+
|
|
348
|
+
// Media keeps changing on its own while it plays or loads.
|
|
349
|
+
const onMediaEvent = () => { forced = true; wake(); };
|
|
350
|
+
root.addEventListener("load", onMediaEvent, true);
|
|
351
|
+
root.addEventListener("loadeddata", onMediaEvent, true);
|
|
352
|
+
root.addEventListener("seeked", onMediaEvent, true);
|
|
353
|
+
window.addEventListener("resize", onMediaEvent);
|
|
354
|
+
// A root that started in a background tab draws when the tab is shown.
|
|
355
|
+
document.addEventListener("visibilitychange", onMediaEvent);
|
|
356
|
+
|
|
357
|
+
function wake() {
|
|
358
|
+
if (!frame && !destroyed) frame = requestAnimationFrame(tick);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function tick() {
|
|
362
|
+
frame = 0;
|
|
363
|
+
// Hidden tabs get no animation frames anyway; no need to check here.
|
|
364
|
+
if (destroyed || !visible) return;
|
|
365
|
+
let animating = false;
|
|
366
|
+
const ground = groundColor();
|
|
367
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
368
|
+
const playing = media.some(
|
|
369
|
+
(element) => element instanceof HTMLVideoElement && !element.paused && !element.ended,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
for (const pane of panes) {
|
|
373
|
+
pane.hover += (pane.hoverTarget - pane.hover) * 0.25;
|
|
374
|
+
pane.press += (pane.pressTarget - pane.press) * 0.35;
|
|
375
|
+
if (Math.abs(pane.hoverTarget - pane.hover) < 0.01) pane.hover = pane.hoverTarget;
|
|
376
|
+
if (Math.abs(pane.pressTarget - pane.press) < 0.01) pane.press = pane.pressTarget;
|
|
377
|
+
if (pane.hover !== pane.hoverTarget || pane.press !== pane.pressTarget) animating = true;
|
|
378
|
+
renderPane(pane, ground, dpr, forced || playing);
|
|
379
|
+
}
|
|
380
|
+
forced = false;
|
|
381
|
+
// Keep polling positions while anything could be moving: transforms and
|
|
382
|
+
// scroll offsets inside the root change without a DOM mutation.
|
|
383
|
+
if (animating || playing || media.length > 0) frame = requestAnimationFrame(tick);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderPane(pane: Pane, ground: string, dpr: number, force: boolean) {
|
|
387
|
+
const { element } = pane;
|
|
388
|
+
const rect = element.getBoundingClientRect();
|
|
389
|
+
if (rect.width < 1 || rect.height < 1) return;
|
|
390
|
+
const style = getComputedStyle(element);
|
|
391
|
+
const config = { ...defaults, ...parseConfig(element.dataset.config) };
|
|
392
|
+
const cssRadius = parseFloat(style.borderTopLeftRadius) || 0;
|
|
393
|
+
const glass = resolveGlass(config, rect.width, rect.height, cssRadius);
|
|
394
|
+
|
|
395
|
+
const pad = samplePadding(glass) + OVERFLOW;
|
|
396
|
+
const region: Box = {
|
|
397
|
+
x: rect.left - pad,
|
|
398
|
+
y: rect.top - pad,
|
|
399
|
+
width: rect.width + pad * 2,
|
|
400
|
+
height: rect.height + pad * 2,
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// What is behind the pane now, cheaply: a changed position, crop,
|
|
404
|
+
// opacity or video frame changes this string.
|
|
405
|
+
type Layer = { element: Element; clip: Box; opacity: number; filter: string; color: string | null };
|
|
406
|
+
const drawn: Layer[] = [];
|
|
407
|
+
for (const item of paintList) {
|
|
408
|
+
const isMedia = item instanceof HTMLImageElement || item instanceof HTMLVideoElement || item instanceof HTMLCanvasElement;
|
|
409
|
+
if (isMedia && !drawable(item)) continue;
|
|
410
|
+
if (!intersect(toBox(item.getBoundingClientRect()), region)) continue;
|
|
411
|
+
const seen = visibility(item, root);
|
|
412
|
+
if (!seen) continue;
|
|
413
|
+
const clip = intersect(seen.clip, region);
|
|
414
|
+
if (!clip) continue;
|
|
415
|
+
const itemStyle = getComputedStyle(item);
|
|
416
|
+
const color = isMedia ? null : itemStyle.backgroundColor;
|
|
417
|
+
if (color !== null && transparent(color)) continue;
|
|
418
|
+
drawn.push({
|
|
419
|
+
element: item,
|
|
420
|
+
clip,
|
|
421
|
+
opacity: seen.opacity,
|
|
422
|
+
filter: itemStyle.filter && itemStyle.filter !== "none" ? itemStyle.filter : "none",
|
|
423
|
+
color,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const signature = [
|
|
428
|
+
region.x.toFixed(1), region.y.toFixed(1), region.width.toFixed(1), region.height.toFixed(1),
|
|
429
|
+
dpr, ground, element.dataset.config ?? "", pane.hover.toFixed(2), pane.press.toFixed(2),
|
|
430
|
+
...drawn.map(({ element: item, clip, opacity, filter, color }) => {
|
|
431
|
+
const r = item.getBoundingClientRect();
|
|
432
|
+
const frameKey = item instanceof HTMLVideoElement ? item.currentTime.toFixed(3)
|
|
433
|
+
: item instanceof HTMLImageElement ? item.currentSrc : "";
|
|
434
|
+
return `${r.x.toFixed(1)},${r.y.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)}|${clip.x.toFixed(1)},${clip.y.toFixed(1)},${clip.width.toFixed(1)},${clip.height.toFixed(1)}|${opacity.toFixed(2)}|${filter}|${color ?? ""}|${frameKey}`;
|
|
435
|
+
}),
|
|
436
|
+
].join(";");
|
|
437
|
+
if (!force && signature === pane.signature) return;
|
|
438
|
+
pane.signature = signature;
|
|
439
|
+
|
|
440
|
+
// The ground, in scene space (CSS px, origin at the region's top-left).
|
|
441
|
+
let scale = dpr;
|
|
442
|
+
if (region.width * region.height * scale * scale > MAX_SCENE_PIXELS) {
|
|
443
|
+
scale = Math.sqrt(MAX_SCENE_PIXELS / (region.width * region.height));
|
|
444
|
+
}
|
|
445
|
+
scene.width = Math.max(1, Math.round(region.width * scale));
|
|
446
|
+
scene.height = Math.max(1, Math.round(region.height * scale));
|
|
447
|
+
sceneContext.setTransform(scale, 0, 0, scale, -region.x * scale, -region.y * scale);
|
|
448
|
+
sceneContext.globalAlpha = 1;
|
|
449
|
+
sceneContext.fillStyle = ground;
|
|
450
|
+
sceneContext.fillRect(region.x, region.y, region.width, region.height);
|
|
451
|
+
for (const { element: item, clip, opacity, filter, color } of drawn) {
|
|
452
|
+
sceneContext.save();
|
|
453
|
+
sceneContext.globalAlpha = opacity;
|
|
454
|
+
if (color !== null) {
|
|
455
|
+
const box = toBox(item.getBoundingClientRect());
|
|
456
|
+
const visible = intersect(box, clip);
|
|
457
|
+
if (visible) {
|
|
458
|
+
sceneContext.fillStyle = color;
|
|
459
|
+
sceneContext.fillRect(visible.x, visible.y, visible.width, visible.height);
|
|
460
|
+
}
|
|
461
|
+
sceneContext.restore();
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const source = item as HTMLImageElement | HTMLVideoElement | HTMLCanvasElement;
|
|
465
|
+
const box = source.getBoundingClientRect();
|
|
466
|
+
const itemStyle = getComputedStyle(source);
|
|
467
|
+
const content = source instanceof HTMLCanvasElement
|
|
468
|
+
? { x: 0, y: 0, width: box.width, height: box.height }
|
|
469
|
+
: objectFitRect(box, naturalSize(source), itemStyle.objectFit, itemStyle.objectPosition);
|
|
470
|
+
// A filtered image (the lightbox's blurred copy) bleeds past its box;
|
|
471
|
+
// clip to its clipping ancestors only, not to the box itself.
|
|
472
|
+
const bleed = filter === "none" ? clip : intersect(region, clipOf(source)) ?? clip;
|
|
473
|
+
sceneContext.beginPath();
|
|
474
|
+
sceneContext.rect(bleed.x, bleed.y, bleed.width, bleed.height);
|
|
475
|
+
sceneContext.clip();
|
|
476
|
+
// Canvas understands CSS filter functions (blur, brightness, …) as is.
|
|
477
|
+
if (filter !== "none") sceneContext.filter = filter;
|
|
478
|
+
try {
|
|
479
|
+
sceneContext.drawImage(source, box.left + content.x, box.top + content.y, content.width, content.height);
|
|
480
|
+
} catch {
|
|
481
|
+
tainted.add(source);
|
|
482
|
+
}
|
|
483
|
+
sceneContext.restore();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const s = getShared();
|
|
487
|
+
const { gl } = s;
|
|
488
|
+
gl.bindTexture(gl.TEXTURE_2D, s.texture);
|
|
489
|
+
try {
|
|
490
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, scene);
|
|
491
|
+
} catch {
|
|
492
|
+
// A canvas we drew was cross-origin. Leave it out from now on.
|
|
493
|
+
for (const { element: item } of drawn) if (item instanceof HTMLCanvasElement) tainted.add(item);
|
|
494
|
+
pane.signature = "";
|
|
495
|
+
forced = true;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Output: the pane plus room for its shadow, at device resolution.
|
|
500
|
+
const out: Box = {
|
|
501
|
+
x: rect.left - OVERFLOW,
|
|
502
|
+
y: rect.top - OVERFLOW,
|
|
503
|
+
width: rect.width + OVERFLOW * 2,
|
|
504
|
+
height: rect.height + OVERFLOW * 2,
|
|
505
|
+
};
|
|
506
|
+
const width = Math.max(1, Math.round(out.width * dpr));
|
|
507
|
+
const height = Math.max(1, Math.round(out.height * dpr));
|
|
508
|
+
if (s.canvas.width !== width || s.canvas.height !== height) {
|
|
509
|
+
s.canvas.width = width;
|
|
510
|
+
s.canvas.height = height;
|
|
511
|
+
}
|
|
512
|
+
gl.viewport(0, 0, width, height);
|
|
513
|
+
gl.clearColor(0, 0, 0, 0);
|
|
514
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
515
|
+
|
|
516
|
+
const set1 = (name: string, value: number) => gl.uniform1f(uniform(s, name), value);
|
|
517
|
+
const set2 = (name: string, a: number, b: number) => gl.uniform2f(uniform(s, name), a, b);
|
|
518
|
+
set2("u_sceneSize", region.width, region.height);
|
|
519
|
+
set2("u_origin", out.x - region.x, out.y - region.y);
|
|
520
|
+
set2("u_outSize", out.width, out.height);
|
|
521
|
+
set2("u_center", rect.left + rect.width / 2 - region.x, rect.top + rect.height / 2 - region.y);
|
|
522
|
+
set2("u_half", rect.width / 2, rect.height / 2);
|
|
523
|
+
set1("u_dpr", dpr);
|
|
524
|
+
const values: Array<[string, number]> = [
|
|
525
|
+
["u_radius", glass.radius], ["u_power", glass.power], ["u_bezel", glass.bezel],
|
|
526
|
+
["u_bend", glass.bend], ["u_dispersionRed", glass.dispersionRed],
|
|
527
|
+
["u_dispersionBlue", glass.dispersionBlue], ["u_rim", glass.rim],
|
|
528
|
+
["u_rimWidth", glass.rimWidth], ["u_specular", glass.specular], ["u_frost", glass.frost],
|
|
529
|
+
["u_grain", glass.grain], ["u_dim", glass.dim], ["u_fadeStart", glass.fadeStart],
|
|
530
|
+
["u_fadeEnd", glass.fadeEnd], ["u_shadow", glass.shadow],
|
|
531
|
+
["u_hover", glass.button ? pane.hover : 0], ["u_press", glass.button ? pane.press : 0],
|
|
532
|
+
];
|
|
533
|
+
for (const [name, value] of values) set1(name, value);
|
|
534
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
535
|
+
|
|
536
|
+
// Place the pane's canvas over its box, past its border, with room for
|
|
537
|
+
// the shadow, and copy the frame in before the drawing buffer is cleared.
|
|
538
|
+
const borderLeft = parseFloat(style.borderLeftWidth) || 0;
|
|
539
|
+
const borderTop = parseFloat(style.borderTopWidth) || 0;
|
|
540
|
+
const { canvas, context } = pane;
|
|
541
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
542
|
+
canvas.width = width;
|
|
543
|
+
canvas.height = height;
|
|
544
|
+
}
|
|
545
|
+
const place = {
|
|
546
|
+
left: `${-OVERFLOW - borderLeft}px`,
|
|
547
|
+
top: `${-OVERFLOW - borderTop}px`,
|
|
548
|
+
width: `${out.width}px`,
|
|
549
|
+
height: `${out.height}px`,
|
|
550
|
+
};
|
|
551
|
+
for (const [key, value] of Object.entries(place) as Array<[keyof typeof place, string]>) {
|
|
552
|
+
if (canvas.style[key] !== value) canvas.style[key] = value;
|
|
553
|
+
}
|
|
554
|
+
context.clearRect(0, 0, width, height);
|
|
555
|
+
context.drawImage(s.canvas, 0, 0);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const instance: LensGlass = {
|
|
559
|
+
markChanged() {
|
|
560
|
+
forced = true;
|
|
561
|
+
wake();
|
|
562
|
+
},
|
|
563
|
+
destroy() {
|
|
564
|
+
destroyed = true;
|
|
565
|
+
if (frame) cancelAnimationFrame(frame);
|
|
566
|
+
intersection.disconnect();
|
|
567
|
+
mutations.disconnect();
|
|
568
|
+
root.removeEventListener("load", onMediaEvent, true);
|
|
569
|
+
root.removeEventListener("loadeddata", onMediaEvent, true);
|
|
570
|
+
root.removeEventListener("seeked", onMediaEvent, true);
|
|
571
|
+
window.removeEventListener("resize", onMediaEvent);
|
|
572
|
+
document.removeEventListener("visibilitychange", onMediaEvent);
|
|
573
|
+
for (const pane of panes) {
|
|
574
|
+
pane.cleanup();
|
|
575
|
+
pane.canvas.remove();
|
|
576
|
+
if (pane.restorePosition !== null) pane.element.style.position = pane.restorePosition;
|
|
577
|
+
}
|
|
578
|
+
instances.delete(instance);
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
instances.add(instance);
|
|
582
|
+
wake();
|
|
583
|
+
return instance;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export type { GlassConfig, ResolvedGlass };
|