@boyernick/standard-ui-react 0.4.2-canary.63 → 0.4.2-canary.65
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/lib/lens-glass/renderer.ts +120 -17
- package/src/modal.tsx +3 -1
- package/src/video-player.tsx +5 -1
package/package.json
CHANGED
|
@@ -114,6 +114,23 @@ const instances = new Set<{ markChanged(): void }>();
|
|
|
114
114
|
// Reading what is behind a pane
|
|
115
115
|
// ────────────────────────────────────────────────────────────────
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Layout does not change within a frame (the renderer only moves its own
|
|
119
|
+
* absolutely positioned canvases), so each element is measured once per frame
|
|
120
|
+
* however many panes look at it.
|
|
121
|
+
*/
|
|
122
|
+
let frameRects: Map<Element, DOMRect> | null = null;
|
|
123
|
+
|
|
124
|
+
function rectOf(element: Element) {
|
|
125
|
+
if (!frameRects) return element.getBoundingClientRect();
|
|
126
|
+
let rect = frameRects.get(element);
|
|
127
|
+
if (!rect) {
|
|
128
|
+
rect = element.getBoundingClientRect();
|
|
129
|
+
frameRects.set(element, rect);
|
|
130
|
+
}
|
|
131
|
+
return rect;
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
const toBox = (rect: DOMRect): Box => ({
|
|
118
135
|
x: rect.left,
|
|
119
136
|
y: rect.top,
|
|
@@ -127,15 +144,24 @@ const transparent = (color: string) =>
|
|
|
127
144
|
/** Media whose pixels a canvas cannot read without tainting it. */
|
|
128
145
|
const tainted = new WeakSet<Element>();
|
|
129
146
|
|
|
147
|
+
const originChecks = new WeakMap<Element, { source: string; cross: boolean }>();
|
|
148
|
+
|
|
130
149
|
function crossOrigin(element: HTMLImageElement | HTMLVideoElement) {
|
|
131
150
|
if (element.crossOrigin) return false;
|
|
132
151
|
const source = element.currentSrc || element.getAttribute("src") || "";
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
152
|
+
const known = originChecks.get(element);
|
|
153
|
+
if (known && known.source === source) return known.cross;
|
|
154
|
+
let cross: boolean;
|
|
155
|
+
if (!source || source.startsWith("data:") || source.startsWith("blob:")) cross = false;
|
|
156
|
+
else {
|
|
157
|
+
try {
|
|
158
|
+
cross = new URL(source, location.href).origin !== location.origin;
|
|
159
|
+
} catch {
|
|
160
|
+
cross = true;
|
|
161
|
+
}
|
|
138
162
|
}
|
|
163
|
+
originChecks.set(element, { source, cross });
|
|
164
|
+
return cross;
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
function drawable(element: Element): element is HTMLImageElement | HTMLVideoElement | HTMLCanvasElement {
|
|
@@ -149,6 +175,71 @@ function drawable(element: Element): element is HTMLImageElement | HTMLVideoElem
|
|
|
149
175
|
return element instanceof HTMLCanvasElement && element.width > 0 && element.height > 0;
|
|
150
176
|
}
|
|
151
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Images are drawn from a downsized copy decoded off the main thread. Drawing
|
|
180
|
+
* a full-size <img> the first time decodes it synchronously — a Retina
|
|
181
|
+
* screenshot behind a carousel arrow held a frame for 380ms — and the glass
|
|
182
|
+
* frosts what it shows anyway, so it never needs more than this.
|
|
183
|
+
*/
|
|
184
|
+
const MAX_BITMAP_EDGE = 960;
|
|
185
|
+
|
|
186
|
+
type ImageCopy = { src: string; bitmap?: ImageBitmap; pending?: boolean; failed?: boolean };
|
|
187
|
+
const imageCopies = new WeakMap<HTMLImageElement, ImageCopy>();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The drawable copy of an image: a ready bitmap, the image itself where
|
|
191
|
+
* bitmaps are unavailable, or null while the copy is still decoding (it is
|
|
192
|
+
* left out until `ready` is called).
|
|
193
|
+
*/
|
|
194
|
+
function imageCopy(image: HTMLImageElement, ready: () => void): CanvasImageSource | null {
|
|
195
|
+
if (typeof createImageBitmap !== "function") return image;
|
|
196
|
+
const src = image.currentSrc || image.src;
|
|
197
|
+
let copy = imageCopies.get(image);
|
|
198
|
+
if (!copy || copy.src !== src) {
|
|
199
|
+
copy?.bitmap?.close();
|
|
200
|
+
copy = { src };
|
|
201
|
+
imageCopies.set(image, copy);
|
|
202
|
+
}
|
|
203
|
+
if (copy.bitmap) return copy.bitmap;
|
|
204
|
+
if (copy.failed) return image;
|
|
205
|
+
if (!copy.pending) {
|
|
206
|
+
const target = copy;
|
|
207
|
+
target.pending = true;
|
|
208
|
+
const longEdge = Math.max(image.naturalWidth, image.naturalHeight);
|
|
209
|
+
const scale = Math.min(1, MAX_BITMAP_EDGE / Math.max(1, longEdge));
|
|
210
|
+
const options: ImageBitmapOptions | undefined = scale < 1
|
|
211
|
+
? {
|
|
212
|
+
resizeWidth: Math.max(1, Math.round(image.naturalWidth * scale)),
|
|
213
|
+
resizeHeight: Math.max(1, Math.round(image.naturalHeight * scale)),
|
|
214
|
+
resizeQuality: "medium",
|
|
215
|
+
}
|
|
216
|
+
: undefined;
|
|
217
|
+
// Decode from the file's bytes (the HTTP cache has them): a Blob decodes
|
|
218
|
+
// entirely off the main thread, where an <img> source can still decode on
|
|
219
|
+
// it. SVG and anything else a Blob cannot decode falls back to the element.
|
|
220
|
+
const fromBytes = fetch(src, { mode: "cors", credentials: "same-origin", cache: "force-cache" })
|
|
221
|
+
.then((response) => {
|
|
222
|
+
if (!response.ok) throw new Error(`Glass: image fetch ${response.status}`);
|
|
223
|
+
return response.blob();
|
|
224
|
+
})
|
|
225
|
+
.then((blob) => createImageBitmap(blob, options));
|
|
226
|
+
fromBytes
|
|
227
|
+
.catch(() => createImageBitmap(image, options))
|
|
228
|
+
.then((bitmap) => {
|
|
229
|
+
if (imageCopies.get(image) === target) target.bitmap = bitmap;
|
|
230
|
+
else bitmap.close();
|
|
231
|
+
})
|
|
232
|
+
.catch(() => {
|
|
233
|
+
target.failed = true;
|
|
234
|
+
})
|
|
235
|
+
.finally(() => {
|
|
236
|
+
target.pending = false;
|
|
237
|
+
ready();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
152
243
|
/** Whether drawing this source would taint a canvas (its pixels are cross-origin). */
|
|
153
244
|
function unreadable(source: CanvasImageSource) {
|
|
154
245
|
const probe = document.createElement("canvas");
|
|
@@ -175,7 +266,7 @@ function naturalSize(element: HTMLImageElement | HTMLVideoElement | HTMLCanvasEl
|
|
|
175
266
|
* up to the root, and its opacity after every ancestor's. Null if hidden.
|
|
176
267
|
*/
|
|
177
268
|
function visibility(element: Element, root: Element) {
|
|
178
|
-
let clip: Box | null = toBox(element
|
|
269
|
+
let clip: Box | null = toBox(rectOf(element));
|
|
179
270
|
let opacity = 1;
|
|
180
271
|
for (let node: Element | null = element; node && node !== root; node = node.parentElement) {
|
|
181
272
|
const style = getComputedStyle(node);
|
|
@@ -183,7 +274,7 @@ function visibility(element: Element, root: Element) {
|
|
|
183
274
|
opacity *= Number(style.opacity) || 0;
|
|
184
275
|
if (opacity <= 0.01) return null;
|
|
185
276
|
if (node !== element && (style.overflowX !== "visible" || style.overflowY !== "visible")) {
|
|
186
|
-
clip = clip && intersect(clip, toBox(node
|
|
277
|
+
clip = clip && intersect(clip, toBox(rectOf(node)));
|
|
187
278
|
if (!clip) return null;
|
|
188
279
|
}
|
|
189
280
|
}
|
|
@@ -196,7 +287,7 @@ function clipOf(element: Element): Box {
|
|
|
196
287
|
for (let node = element.parentElement; node; node = node.parentElement) {
|
|
197
288
|
const style = getComputedStyle(node);
|
|
198
289
|
if (style.overflowX !== "visible" || style.overflowY !== "visible") {
|
|
199
|
-
clip = intersect(clip, toBox(node
|
|
290
|
+
clip = intersect(clip, toBox(rectOf(node))) ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
200
291
|
}
|
|
201
292
|
if (node.dataset?.slot === "glass-root") break;
|
|
202
293
|
}
|
|
@@ -404,7 +495,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
404
495
|
}
|
|
405
496
|
|
|
406
497
|
const rectKey = (element: Element) => {
|
|
407
|
-
const r = element
|
|
498
|
+
const r = rectOf(element);
|
|
408
499
|
return `${r.x.toFixed(1)},${r.y.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)}`;
|
|
409
500
|
};
|
|
410
501
|
|
|
@@ -419,6 +510,15 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
419
510
|
frame = 0;
|
|
420
511
|
// Hidden tabs get no animation frames anyway; no need to check here.
|
|
421
512
|
if (destroyed || !visible) return;
|
|
513
|
+
frameRects = new Map();
|
|
514
|
+
try {
|
|
515
|
+
step(now);
|
|
516
|
+
} finally {
|
|
517
|
+
frameRects = null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function step(now: number) {
|
|
422
522
|
|
|
423
523
|
// Cheap: where the panes and media are. Anything else that matters
|
|
424
524
|
// arrives as an event and sets `forced`.
|
|
@@ -453,8 +553,8 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
453
553
|
|
|
454
554
|
// A playing video behind a shown pane changes every frame; follow it
|
|
455
555
|
// at up to 30fps.
|
|
456
|
-
const paneBox = toBox(pane.element
|
|
457
|
-
const overVideo = playing.some((video) => intersect(toBox(video
|
|
556
|
+
const paneBox = toBox(rectOf(pane.element));
|
|
557
|
+
const overVideo = playing.some((video) => intersect(toBox(rectOf(video)), paneBox));
|
|
458
558
|
let videoFrame = false;
|
|
459
559
|
if (overVideo) {
|
|
460
560
|
videoBehindShownPane = true;
|
|
@@ -475,7 +575,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
475
575
|
|
|
476
576
|
function renderPane(pane: Pane, ground: string, dpr: number, force: boolean) {
|
|
477
577
|
const { element } = pane;
|
|
478
|
-
const rect = element
|
|
578
|
+
const rect = rectOf(element);
|
|
479
579
|
if (rect.width < 1 || rect.height < 1) return;
|
|
480
580
|
const style = getComputedStyle(element);
|
|
481
581
|
const config = { ...defaults, ...parseConfig(element.dataset.config) };
|
|
@@ -496,8 +596,8 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
496
596
|
const drawn: Layer[] = [];
|
|
497
597
|
for (const item of paintList) {
|
|
498
598
|
const isMedia = item instanceof HTMLImageElement || item instanceof HTMLVideoElement || item instanceof HTMLCanvasElement;
|
|
599
|
+
if (!intersect(toBox(rectOf(item)), region)) continue;
|
|
499
600
|
if (isMedia && !drawable(item)) continue;
|
|
500
|
-
if (!intersect(toBox(item.getBoundingClientRect()), region)) continue;
|
|
501
601
|
const seen = visibility(item, root);
|
|
502
602
|
if (!seen) continue;
|
|
503
603
|
const clip = intersect(seen.clip, region);
|
|
@@ -524,7 +624,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
524
624
|
px(region.width), px(region.height),
|
|
525
625
|
dpr, ground, element.dataset.config ?? "", pane.hover.toFixed(2), pane.press.toFixed(2),
|
|
526
626
|
...drawn.map(({ element: item, clip, opacity, filter, color }) => {
|
|
527
|
-
const r = item
|
|
627
|
+
const r = rectOf(item);
|
|
528
628
|
const frameKey = item instanceof HTMLVideoElement ? item.currentTime.toFixed(3)
|
|
529
629
|
: item instanceof HTMLImageElement ? item.currentSrc : "";
|
|
530
630
|
return `${px(r.x - ox)},${px(r.y - oy)},${px(r.width)},${px(r.height)}|${px(clip.x - ox)},${px(clip.y - oy)},${px(clip.width)},${px(clip.height)}|${opacity.toFixed(2)}|${filter}|${color ?? ""}|${frameKey}`;
|
|
@@ -556,7 +656,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
556
656
|
sceneContext.save();
|
|
557
657
|
sceneContext.globalAlpha = opacity;
|
|
558
658
|
if (color !== null) {
|
|
559
|
-
const box = toBox(item
|
|
659
|
+
const box = toBox(rectOf(item));
|
|
560
660
|
const visible = intersect(box, clip);
|
|
561
661
|
if (visible) {
|
|
562
662
|
sceneContext.fillStyle = color;
|
|
@@ -566,7 +666,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
566
666
|
continue;
|
|
567
667
|
}
|
|
568
668
|
const source = item as HTMLImageElement | HTMLVideoElement | HTMLCanvasElement;
|
|
569
|
-
const box = source
|
|
669
|
+
const box = rectOf(source);
|
|
570
670
|
const itemStyle = getComputedStyle(source);
|
|
571
671
|
const content = source instanceof HTMLCanvasElement
|
|
572
672
|
? { x: 0, y: 0, width: box.width, height: box.height }
|
|
@@ -579,8 +679,11 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
579
679
|
sceneContext.clip();
|
|
580
680
|
// Canvas understands CSS filter functions (blur, brightness, …) as is.
|
|
581
681
|
if (filter !== "none") sceneContext.filter = filter;
|
|
682
|
+
const picture = source instanceof HTMLImageElement ? imageCopy(source, instance.markChanged) : source;
|
|
582
683
|
try {
|
|
583
|
-
|
|
684
|
+
if (picture) {
|
|
685
|
+
sceneContext.drawImage(picture, box.left + content.x, box.top + content.y, content.width, content.height);
|
|
686
|
+
}
|
|
584
687
|
} catch {
|
|
585
688
|
tainted.add(source);
|
|
586
689
|
}
|
package/src/modal.tsx
CHANGED
|
@@ -149,7 +149,9 @@ export const ModalTrigger = ({
|
|
|
149
149
|
)
|
|
150
150
|
|
|
151
151
|
const ModalBackdrop = () => (
|
|
152
|
-
|
|
152
|
+
// DialogBackdrop is black/60 in dark mode; say 95 there too, or the page
|
|
153
|
+
// shows through behind the image.
|
|
154
|
+
<DialogBackdrop className="bg-surface-scrim/95 dark:bg-surface-scrim/95" />
|
|
153
155
|
)
|
|
154
156
|
|
|
155
157
|
const ModalBackground = ({ src }: { src: string }) => (
|
package/src/video-player.tsx
CHANGED
|
@@ -320,7 +320,11 @@ export const VideoPlayer = ({
|
|
|
320
320
|
// carries its own hairline ring and doubled the frame. Black only in
|
|
321
321
|
// fullscreen, where letterboxing needs it: behind the rounded clip it
|
|
322
322
|
// bled through the video's anti-aliased corners as a dark stroke.
|
|
323
|
-
|
|
323
|
+
// The corners stay rounded in fullscreen: browsers keep :fullscreen
|
|
324
|
+
// matching through their exit animation, so a square corner collapsed
|
|
325
|
+
// back into the page square and only rounded once it landed. Against
|
|
326
|
+
// the black backdrop the rounding does not show while fullscreen.
|
|
327
|
+
"group relative isolate overflow-hidden rounded-xl shadow-[var(--shadow-300)] ring-1 ring-border-primary data-[fullscreen]:h-screen data-[fullscreen]:w-screen data-[fullscreen]:bg-black data-[fullscreen]:ring-0 fullscreen:h-screen fullscreen:w-screen fullscreen:bg-black fullscreen:ring-0",
|
|
324
328
|
className,
|
|
325
329
|
)}
|
|
326
330
|
aria-busy={loading || buffering || undefined}
|