@boyernick/standard-ui-react 0.4.2-canary.63 → 0.4.2-canary.64

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyernick/standard-ui-react",
3
- "version": "0.4.2-canary.63",
3
+ "version": "0.4.2-canary.64",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- 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;
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.getBoundingClientRect());
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.getBoundingClientRect()));
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.getBoundingClientRect())) ?? { x: 0, y: 0, width: 0, height: 0 };
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.getBoundingClientRect();
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.getBoundingClientRect());
457
- const overVideo = playing.some((video) => intersect(toBox(video.getBoundingClientRect()), paneBox));
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.getBoundingClientRect();
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.getBoundingClientRect();
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.getBoundingClientRect());
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.getBoundingClientRect();
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
- sceneContext.drawImage(source, box.left + content.x, box.top + content.y, content.width, content.height);
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
  }
@@ -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
- "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]:rounded-none data-[fullscreen]:bg-black data-[fullscreen]:ring-0 fullscreen:h-screen fullscreen:w-screen fullscreen:rounded-none fullscreen:bg-black fullscreen:ring-0",
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}